Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbee9c8849 | ||
|
|
ebddf6d35f | ||
|
|
314b4977be | ||
|
|
165ab7bf74 | ||
|
|
81e2422277 | ||
|
|
e3e4e5495c | ||
|
|
54624bf328 | ||
|
|
4011807a54 |
No files matched your search
@@ -1,6 +0,0 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.env
|
||||
**/*.env
|
||||
.git
|
||||
docker/dev/mongo/data
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -68,23 +68,19 @@ VITE_APP_LOG_LEVEL="debug"
|
||||
### Backend Layers
|
||||
```
|
||||
server/src/
|
||||
├── api/ # HTTP layer
|
||||
│ ├── controllers/ # Route handlers (authController, monitorController, etc.)
|
||||
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
||||
│ ├── routes/ # API route definitions
|
||||
│ └── validation/ # Zod request-payload schemas
|
||||
├── domain/ # Business logic + data access, one folder per entity
|
||||
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
|
||||
│ ├── checks/
|
||||
│ ├── incidents/
|
||||
│ └── … # users, teams, status-pages, notifications, etc.
|
||||
├── service/ # Cross-cutting services (e.g. infrastructure/)
|
||||
├── config/ # App config wiring (controllers, routes, services, envValidation)
|
||||
├── controllers/ # Route handlers (authController, monitorController, etc.)
|
||||
├── service/ # Business logic
|
||||
│ ├── business/ # Core monitoring logic
|
||||
│ ├── infrastructure/ # Server/system utilities
|
||||
│ └── system/ # App-level settings
|
||||
├── db/
|
||||
│ └── migration/ # Database migrations (run on startup)
|
||||
├── templates/ # Email/notification templates
|
||||
├── types/ # Shared const tuples + derived types
|
||||
└── utils/ # Shared utilities
|
||||
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
|
||||
│ ├── migration/ # Database migrations (run on startup)
|
||||
│ └── modules/ # Database-specific modules
|
||||
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
||||
├── routes/v1/ # API route definitions
|
||||
├── validation/ # Joi input validation schemas
|
||||
└── repositories/ # Data access layer
|
||||
```
|
||||
|
||||
### Frontend Structure
|
||||
|
||||
+25
-31
@@ -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)
|
||||
@@ -127,7 +151,6 @@ LOG_LEVEL="debug"
|
||||
- `ORIGIN`: Origin for CORS purposes
|
||||
- `TOKEN_TTL`: Token time to live (in vercel/ms format)
|
||||
- `LOG_LEVEL`: Debug level (debug, info, warn, error)
|
||||
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`)
|
||||
|
||||
Start the backend server:
|
||||
|
||||
@@ -232,35 +255,6 @@ docker rm uptime_database_mongo
|
||||
|
||||
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
[](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!
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: checkmate-chart
|
||||
description: A Helm chart for Checkmate App
|
||||
type: application
|
||||
version: 0.3.0
|
||||
appVersion: "latest" # the all-in-one image publishes :latest on release tags; pin a version here to freeze
|
||||
version: 0.1.0
|
||||
appVersion: "2.3"
|
||||
@@ -19,11 +19,12 @@ cd checkmate/charts/helm/checkmate
|
||||
|
||||
### 2. Customize values.yaml
|
||||
Edit `values.yaml` to update:
|
||||
- `app.ingress.host` with your domain name
|
||||
- `app.protocol` (usually http or https)
|
||||
- `client.ingress.host` and `server.ingress.host` with your domain names
|
||||
- `server.protocol` (usually http or https)
|
||||
- **If upgrading**: Migrate persistence settings from flat structure to nested:
|
||||
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
|
||||
- Add: `persistence.mongo.storageClass` (leave empty for default)
|
||||
- Old: `persistence.redisSize` → New: `persistence.redis.size`
|
||||
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
|
||||
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
|
||||
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
|
||||
|
||||
@@ -31,7 +32,7 @@ Edit `values.yaml` to update:
|
||||
```bash
|
||||
helm install checkmate ./charts/helm/checkmate
|
||||
```
|
||||
This will deploy the client, api, and MongoDB components.
|
||||
This will deploy the client, server, MongoDB, and Redis components.
|
||||
|
||||
### 4. Verify the deployment
|
||||
Check pods and services:
|
||||
@@ -42,137 +43,6 @@ kubectl get svc
|
||||
|
||||
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
|
||||
|
||||
## Upgrading from a previous release
|
||||
|
||||
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
|
||||
Read this before running `helm upgrade` on an existing install.
|
||||
|
||||
### `server.*` → `api.*` (values rename)
|
||||
|
||||
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
|
||||
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
|
||||
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
|
||||
|
||||
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
|
||||
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
|
||||
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
|
||||
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
|
||||
don't mix them.
|
||||
|
||||
### Resource renames cause a replace, not a rename
|
||||
|
||||
The Kubernetes objects are renamed alongside the values:
|
||||
|
||||
| Old object | New object |
|
||||
| --- | --- |
|
||||
| Deployment/Service `checkmate-server` | `checkmate-api` |
|
||||
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
|
||||
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
|
||||
|
||||
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
|
||||
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
|
||||
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
|
||||
|
||||
### Redis removed
|
||||
|
||||
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
|
||||
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
|
||||
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
|
||||
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
|
||||
keys in your values file are harmless (ignored).
|
||||
|
||||
### New secret defaults
|
||||
|
||||
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
|
||||
block and merge into your install unless you override them. Set them explicitly if you relied on
|
||||
different values (e.g. a non-production `NODE_ENV`).
|
||||
|
||||
### Image tags follow the chart's `appVersion`
|
||||
|
||||
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
|
||||
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
|
||||
chart. You can still pin per tier:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
image: ghcr.io/bluewave-labs/checkmate # repo only
|
||||
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
|
||||
```
|
||||
|
||||
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
|
||||
`api.image`) unless you set `worker.tag` / `worker.image`.
|
||||
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
|
||||
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
|
||||
|
||||
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
|
||||
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
|
||||
let it default to `appVersion`) for reproducible, rollback-able deploys.
|
||||
|
||||
## Scaling: the worker tier & autoscaling
|
||||
|
||||
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
|
||||
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
|
||||
horizontally-scalable worker tier.
|
||||
|
||||
### Topology
|
||||
|
||||
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
|
||||
| --- | --- | --- |
|
||||
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
|
||||
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
|
||||
|
||||
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
|
||||
and worker tier can never both process (or both ignore) the queue.
|
||||
|
||||
### Enabling the worker tier
|
||||
|
||||
```yaml
|
||||
worker:
|
||||
enabled: true
|
||||
replicas: 2 # used only when autoscaling.enabled = false
|
||||
```
|
||||
|
||||
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
|
||||
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
|
||||
|
||||
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
|
||||
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
|
||||
> checks.
|
||||
|
||||
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
|
||||
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
|
||||
> this; full failover to the API would need leader election (not supported).
|
||||
|
||||
### Autoscaling with KEDA
|
||||
|
||||
Backlog-driven autoscaling is **off by default** and requires the
|
||||
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
|
||||
|
||||
```yaml
|
||||
worker:
|
||||
enabled: true
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
|
||||
maxReplicaCount: 10
|
||||
backlogPerReplica: 50 # target due-job backlog per worker
|
||||
mongo:
|
||||
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
|
||||
```
|
||||
|
||||
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
|
||||
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
|
||||
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
|
||||
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
|
||||
backlog → never scales up).
|
||||
|
||||
#### Fallback: Prometheus-adapter HPA (not shipped)
|
||||
|
||||
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
|
||||
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
|
||||
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
|
||||
`now` in code on this path, so the query coercion is unnecessary.
|
||||
|
||||
## Enabling TLS/HTTPS with cert-manager
|
||||
|
||||
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
|
||||
@@ -198,7 +68,7 @@ client:
|
||||
enabled: true
|
||||
secretName: checkmate-client-tls
|
||||
|
||||
api:
|
||||
server:
|
||||
protocol: https
|
||||
ingress:
|
||||
enabled: true
|
||||
@@ -208,7 +78,7 @@ api:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: checkmate-api-tls
|
||||
secretName: checkmate-server-tls
|
||||
```
|
||||
|
||||
### Alternative: Using --set flags
|
||||
@@ -218,13 +88,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
|
||||
```bash
|
||||
helm install checkmate ./charts/helm/checkmate \
|
||||
--set client.protocol=https \
|
||||
--set api.protocol=https \
|
||||
--set server.protocol=https \
|
||||
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set client.ingress.tls.enabled=true \
|
||||
--set client.ingress.tls.secretName=checkmate-client-tls \
|
||||
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set api.ingress.tls.enabled=true \
|
||||
--set api.ingress.tls.secretName=checkmate-api-tls
|
||||
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set server.ingress.tls.enabled=true \
|
||||
--set server.ingress.tls.secretName=checkmate-server-tls
|
||||
```
|
||||
|
||||
### Verification
|
||||
@@ -237,48 +107,10 @@ kubectl get certificates
|
||||
|
||||
# Check certificate details
|
||||
kubectl describe certificate checkmate-client-tls
|
||||
kubectl describe certificate checkmate-api-tls
|
||||
kubectl describe certificate checkmate-server-tls
|
||||
|
||||
# Verify the secrets were created
|
||||
kubectl get secrets | grep checkmate-tls
|
||||
```
|
||||
|
||||
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
|
||||
|
||||
## Upgrading to chart 0.3.0 (all-in-one image)
|
||||
|
||||
Checkmate now ships as a single image that serves both the client and the API, so
|
||||
the chart deploys one workload and one ingress instead of two.
|
||||
|
||||
**What changed**
|
||||
|
||||
| 0.2.x | 0.3.0 |
|
||||
|---|---|
|
||||
| `client.*` + `api.*` value blocks | one `app.*` block |
|
||||
| `checkmate-client` + `checkmate-backend` images | `ghcr.io/bluewave-labs/checkmate` |
|
||||
| `ghcr.io/bluewave-labs/checkmate-mongo` | upstream `mongo:8.0` |
|
||||
| Deployment `checkmate-api` + `checkmate-client` | Deployment `checkmate` |
|
||||
| Ingress `checkmate-api-ingress` (`/api/v1`) + client ingress (`/`) | Ingress `checkmate-ingress` (`/`) |
|
||||
| `UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST` | not set — the client is same-origin |
|
||||
|
||||
`appVersion` is no longer frozen at v3.8.1; the chart tracks the published image again.
|
||||
|
||||
**Upgrading an existing install**
|
||||
|
||||
Existing `client.*`, `api.*` and `server.*` overrides are still read and merged over
|
||||
the `app.*` defaults, so most values files upgrade without edits. Two cases fail
|
||||
deliberately rather than deploying something wrong:
|
||||
|
||||
- `client.image` set — that image is no longer published, so the chart stops instead
|
||||
of leaving you with an ImagePullBackOff. Remove the `client:` block.
|
||||
- `client.ingress.host` and `api.ingress.host` set to *different* hosts — one origin
|
||||
now serves both, so the chart cannot honour a split. Set a single `app.ingress.host`.
|
||||
|
||||
Migrate overrides to `app.*` when convenient; the legacy blocks are transitional.
|
||||
|
||||
**One ingress, one host**
|
||||
|
||||
If you previously served the client and API on separate hostnames, external clients
|
||||
now use the single `app.ingress.host`. Update any bookmarks, uptime checks, or DNS
|
||||
records that pointed at the old API hostname.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
{{- /*
|
||||
Back-compat shim for the all-in-one image migration.
|
||||
|
||||
Chart 0.2.x deployed a separate client and API, configured through `client.*` and
|
||||
`api.*` (and, before that, `server.*`). Checkmate now ships one image that serves
|
||||
both, so those collapse into a single `app.*` block.
|
||||
|
||||
Existing installs keep working: any legacy block is merged over the `app.*`
|
||||
defaults, in the order client -> api -> server, so a value set in an older block
|
||||
still wins. Only `image`/`tag` are deliberately NOT inherited from `client.*` —
|
||||
that repo (checkmate-client) is no longer published, so honouring it would pin an
|
||||
install to an image that cannot be pulled.
|
||||
|
||||
New installs have no legacy keys and this is a no-op deepCopy of `.Values.app`.
|
||||
Consume it as:
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
*/}}
|
||||
{{- define "checkmate.app" -}}
|
||||
{{- $app := deepCopy .Values.app -}}
|
||||
{{- with .Values.client -}}
|
||||
{{- $legacy := omit (deepCopy .) "image" "tag" "port" -}}
|
||||
{{- $app = mergeOverwrite $app $legacy -}}
|
||||
{{- end -}}
|
||||
{{- with .Values.api -}}
|
||||
{{- $app = mergeOverwrite $app (deepCopy .) -}}
|
||||
{{- end -}}
|
||||
{{- with .Values.server -}}
|
||||
{{- $app = mergeOverwrite $app (deepCopy .) -}}
|
||||
{{- end -}}
|
||||
{{- $app | toYaml -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /*
|
||||
Deprecated alias kept so any out-of-tree template referencing `checkmate.api`
|
||||
still resolves. Prefer `checkmate.app`.
|
||||
*/}}
|
||||
{{- define "checkmate.api" -}}
|
||||
{{- include "checkmate.app" . -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- /*
|
||||
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
|
||||
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
|
||||
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
|
||||
|
||||
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
|
||||
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
|
||||
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
|
||||
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
|
||||
*/}}
|
||||
{{- define "checkmate.image" -}}
|
||||
{{- $lastSegment := .image | splitList "/" | last -}}
|
||||
{{- if contains ":" $lastSegment -}}
|
||||
{{- .image -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -1,71 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate
|
||||
spec:
|
||||
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate
|
||||
spec:
|
||||
{{- with $app.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $app.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: checkmate
|
||||
image: {{ include "checkmate.image" (dict "image" $app.image "tag" $app.tag "root" $) }}
|
||||
imagePullPolicy: {{ $app.imagePullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ $app.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
|
||||
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the app is
|
||||
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $app.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $app.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ $app.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ $app.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health
|
||||
port: http
|
||||
initialDelaySeconds: {{ $app.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ $app.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ $app.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ $app.probes.readiness.failureThreshold }}
|
||||
{{- with $app.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
# The client is served from this same origin, so CLIENT_HOST defaults to
|
||||
# the app's own ingress URL. `env` takes precedence over `envFrom`, so an
|
||||
# explicit secrets.CLIENT_HOST is honoured by omitting this entry rather
|
||||
# than emitting a value that would silently win over it.
|
||||
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
|
||||
{{- end }}
|
||||
- name: QUEUE_MODE
|
||||
value: "primary"
|
||||
# Derived: when a worker tier is deployed the app only schedules (false); otherwise it is
|
||||
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
|
||||
- name: QUEUE_PRIMARY_PROCESSES
|
||||
value: {{ not .Values.worker.enabled | quote }}
|
||||
@@ -1,35 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
{{- if $app.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-ingress
|
||||
{{- if $app.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := $app.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ $app.ingress.className }}
|
||||
{{- if $app.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ $app.ingress.host }}
|
||||
secretName: {{ default (printf "%s-tls" .Release.Name) $app.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ $app.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
# The application serves both the client and /api/v1 from one origin, so
|
||||
# a single root rule replaces the separate client and API ingresses. No
|
||||
# CORS annotations are needed now that the browser is same-origin.
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate
|
||||
port:
|
||||
number: {{ $app.port }}
|
||||
{{- end }}
|
||||
@@ -1,10 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate
|
||||
ports:
|
||||
- port: {{ $app.port }}
|
||||
@@ -0,0 +1,44 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-client
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-client
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-client
|
||||
spec:
|
||||
{{- with .Values.client.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.client.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: client
|
||||
image: {{ .Values.client.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.client.port }}
|
||||
env:
|
||||
- name: UPTIME_APP_API_BASE_URL
|
||||
value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
|
||||
- name: UPTIME_APP_CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: default.conf
|
||||
{{- with .Values.client.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: checkmate-server-nginx-cm
|
||||
@@ -0,0 +1,31 @@
|
||||
{{- if .Values.client.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-client-ingress
|
||||
{{- if .Values.client.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := .Values.client.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.client.ingress.className }}
|
||||
{{- if .Values.client.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.client.ingress.host }}
|
||||
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.client.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-client
|
||||
port:
|
||||
number: {{ .Values.client.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-client
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-client
|
||||
ports:
|
||||
- port: {{ .Values.client.port }}
|
||||
File renamed without changes.
+2
-5
@@ -22,8 +22,7 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: mongodb
|
||||
image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }}
|
||||
imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }}
|
||||
image: {{ .Values.mongodb.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.mongodb.port }}
|
||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||
@@ -38,9 +37,7 @@ spec:
|
||||
- metadata:
|
||||
name: checkmate-mongo-persistent-storage
|
||||
spec:
|
||||
{{- with .Values.persistence.mongo.storageClass }}
|
||||
storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}}
|
||||
{{- end }}
|
||||
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
@@ -1,50 +1,48 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
{{- if eq $app.ingress.host "change_me" }}
|
||||
{{- fail "app.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- if eq .Values.client.ingress.host "change_me" }}
|
||||
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $protocol := $app.protocol }}
|
||||
{{- if not (or (eq $protocol "http") (eq $protocol "https")) }}
|
||||
{{- fail "app.protocol must be either 'http' or 'https'" }}
|
||||
{{- if eq .Values.server.ingress.host "change_me" }}
|
||||
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $serverProtocol := .Values.server.protocol }}
|
||||
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
|
||||
{{- fail "server.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $clientProtocol := .Values.client.protocol }}
|
||||
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
|
||||
{{- fail "client.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
|
||||
{{- if and $app.ingress.tls.enabled (ne $protocol "https") }}
|
||||
{{- fail "app.ingress.tls.enabled is true but app.protocol is not 'https'. Set app.protocol: https to avoid mixed content." }}
|
||||
{{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
|
||||
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
|
||||
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* If client runs on https, API must also be https to avoid mixed content */}}
|
||||
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
|
||||
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
|
||||
{{- $ann := $app.ingress.annotations | default dict }}
|
||||
{{- $hasIssuer := or (hasKey $ann "cert-manager.io/cluster-issuer") (hasKey $ann "cert-manager.io/issuer") }}
|
||||
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
|
||||
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
|
||||
|
||||
{{- if and $app.ingress.tls.enabled (not $hasIssuer) }}
|
||||
{{- fail "app.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
|
||||
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
|
||||
|
||||
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
|
||||
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Upgrade guard. The client and API are one service now, so a values file that still
|
||||
points the two at different hosts cannot be honoured — fail loudly rather than
|
||||
silently dropping one of them.
|
||||
*/}}
|
||||
{{- if and .Values.client .Values.api }}
|
||||
{{- $clientHost := (.Values.client.ingress | default dict).host }}
|
||||
{{- $apiHost := (.Values.api.ingress | default dict).host }}
|
||||
{{- if and $clientHost $apiHost (ne $clientHost $apiHost) }}
|
||||
{{- fail (printf "client.ingress.host (%s) and api.ingress.host (%s) differ, but Checkmate now serves the client and the API from one origin. Set a single app.ingress.host instead." $clientHost $apiHost) }}
|
||||
{{- end }}
|
||||
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
|
||||
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
The client image is no longer published. Point this out rather than letting the
|
||||
install fail later on an ImagePullBackOff nobody can action.
|
||||
*/}}
|
||||
{{- if and .Values.client .Values.client.image }}
|
||||
{{- fail "client.image is set, but the separate client image is no longer published. Remove the client block; the application image in app.image now serves the client." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
|
||||
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
|
||||
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret name can be omitted; we default to <release>-tls in templates */}}
|
||||
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-redis
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-redis
|
||||
ports:
|
||||
- port: {{ .Values.redis.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: checkmate-redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: {{ .Values.redis.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.redis.port }}
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: checkmate-redis-persistent-storage
|
||||
spec:
|
||||
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
|
||||
{{- end }}
|
||||
@@ -4,14 +4,6 @@
|
||||
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- /*
|
||||
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
|
||||
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
|
||||
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
|
||||
the one connection string resolves from every namespace. External/already-qualified hosts are not
|
||||
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
|
||||
*/}}
|
||||
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@@ -19,9 +11,5 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- range $key, $value := $secrets }}
|
||||
{{- if eq $key "DB_CONNECTION_STRING" }}
|
||||
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
|
||||
{{- else }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,37 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-server
|
||||
spec:
|
||||
{{- with .Values.server.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.server.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: {{ .Values.server.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
{{- with .Values.server.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
@@ -0,0 +1,38 @@
|
||||
{{- if .Values.server.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-server-ingress
|
||||
{{- if .Values.server.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := .Values.server.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{/*#annotations:
|
||||
#nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
#nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.server.ingress.className }}
|
||||
{{- if .Values.server.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.server.ingress.host }}
|
||||
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.server.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-server
|
||||
port:
|
||||
number: {{ .Values.server.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,41 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: checkmate-server-nginx-cm
|
||||
data:
|
||||
default.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name checkmate-demo.bluewavelabs.ca;
|
||||
server_tokens off;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# location /api/ {
|
||||
# proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
|
||||
location /api-docs/ {
|
||||
proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-server
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-server
|
||||
ports:
|
||||
- port: {{ .Values.server.port }}
|
||||
@@ -1,70 +0,0 @@
|
||||
{{- $app := include "checkmate.app" . | fromYaml -}}
|
||||
{{- if .Values.worker.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-worker
|
||||
spec:
|
||||
{{- if not .Values.worker.autoscaling.enabled }}
|
||||
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-worker
|
||||
spec:
|
||||
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
|
||||
{{- with .Values.worker.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: worker
|
||||
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $app.image) "tag" (.Values.worker.tag | default $app.tag) "root" $) }}
|
||||
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $app.imagePullPolicy }}
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: {{ .Values.worker.healthPort }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
|
||||
env:
|
||||
# validateEnv requires a URL-shaped CLIENT_HOST, so mirror the app's default
|
||||
# here. Omitted when secrets.CLIENT_HOST is set, since `env` would otherwise
|
||||
# take precedence over the envFrom value and silently discard the override.
|
||||
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
|
||||
{{- end }}
|
||||
- name: QUEUE_MODE
|
||||
value: "worker"
|
||||
- name: HEALTH_PORT
|
||||
value: {{ .Values.worker.healthPort | quote }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: health
|
||||
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: health
|
||||
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
|
||||
{{- with .Values.worker.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,30 +0,0 @@
|
||||
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
|
||||
apiVersion: keda.sh/v1alpha1
|
||||
kind: ScaledObject
|
||||
metadata:
|
||||
name: checkmate-worker
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
name: checkmate-worker
|
||||
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
|
||||
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
|
||||
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
|
||||
advanced:
|
||||
horizontalPodAutoscalerConfig:
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
|
||||
triggers:
|
||||
- type: mongodb
|
||||
metadata:
|
||||
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
|
||||
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
|
||||
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
|
||||
query: >-
|
||||
{ "type": "check", "isActive": true,
|
||||
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
|
||||
"$or": [ { "lockedUntil": null },
|
||||
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
|
||||
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
|
||||
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
|
||||
{{- end }}
|
||||
@@ -1,28 +1,7 @@
|
||||
# Checkmate is published as a single all-in-one application image: the API server
|
||||
# also serves the React client, so one deployment and one ingress cover both.
|
||||
# The `client:` and `api:` blocks from chart 0.2.x are still read for back-compat
|
||||
# (see templates/_helpers.tpl) — `app.*` is the block to set on new installs.
|
||||
app:
|
||||
image: ghcr.io/bluewave-labs/checkmate
|
||||
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||
port: 52345
|
||||
client:
|
||||
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
|
||||
port: 80
|
||||
protocol: http
|
||||
resources: {}
|
||||
affinity: {}
|
||||
tolerations: {}
|
||||
# Liveness/readiness probes hit GET /api/v1/health on the app port (proves the HTTP server is up).
|
||||
probes:
|
||||
liveness:
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readiness:
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
ingress:
|
||||
enabled: true
|
||||
host: change_me
|
||||
@@ -33,65 +12,59 @@ app:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-tls # Optional; defaults to <release>-tls if omitted
|
||||
# Note: when enabling TLS, also set app.protocol: https and add
|
||||
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
|
||||
# Note: when enabling TLS, also set client.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
# Upstream MongoDB. The former ghcr.io/bluewave-labs/checkmate-mongo image is no
|
||||
# longer published; this matches the mongo version in docker/docker-compose.yaml.
|
||||
mongodb:
|
||||
image: mongo
|
||||
tag: "8.0"
|
||||
imagePullPolicy: IfNotPresent
|
||||
port: 27017
|
||||
server:
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
|
||||
port: 52345
|
||||
protocol: http
|
||||
ingress:
|
||||
enabled: true
|
||||
host: change_me
|
||||
className: nginx
|
||||
annotations: {}
|
||||
# Example annotations for cert-manager:
|
||||
# annotations:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
|
||||
# Note: when enabling TLS, also set server.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
worker:
|
||||
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
|
||||
# jobs and the app stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
|
||||
# app is all-in-one and processes jobs itself.
|
||||
redis:
|
||||
enabled: false
|
||||
image: "" # defaults to app.image when empty, same as the application image
|
||||
tag: "" # defaults to app.tag (-> Chart.appVersion) when empty
|
||||
imagePullPolicy: "" # defaults to app.imagePullPolicy when empty
|
||||
replicas: 1 # used only when autoscaling.enabled = false
|
||||
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
|
||||
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
|
||||
resources: {}
|
||||
affinity: {}
|
||||
tolerations: {}
|
||||
probes:
|
||||
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
readiness: # /readyz — flips to 503 on drain
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
autoscaling:
|
||||
enabled: false # requires the KEDA operator installed in-cluster
|
||||
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
|
||||
maxReplicaCount: 10
|
||||
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
|
||||
cooldownPeriod: 300 # seconds before scaling an idle target down
|
||||
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
|
||||
mongo:
|
||||
dbName: uptime_db # must match DB_CONNECTION_STRING's database
|
||||
collection: jobs
|
||||
image: redis:7.2
|
||||
port: 6379
|
||||
|
||||
mongodb:
|
||||
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
|
||||
port: 27017
|
||||
|
||||
secrets:
|
||||
JWT_SECRET: change_me
|
||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
|
||||
# CLIENT_HOST is derived from app.protocol + app.ingress.host by the deployment;
|
||||
# set it here only to override that (e.g. when fronting the app with another proxy).
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: info
|
||||
TOKEN_TTL: 99d
|
||||
# REFRESH_TOKEN_SECRET: change_me
|
||||
# SYSTEM_EMAIL_ADDRESS: test@example.com
|
||||
# SYSTEM_EMAIL_PASSWORD: change_me
|
||||
# SYSTEM_EMAIL_HOST: smtp.example.com
|
||||
# SYSTEM_EMAIL_PORT: "587"
|
||||
# PAGESPEED_API_KEY: change_me
|
||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
|
||||
CLIENT_HOST: change_me
|
||||
# REDIS_HOST: redis
|
||||
# REDIS_PORT: "6379"
|
||||
# DB_TYPE: MongoDB
|
||||
# TOKEN_TTL: 99d
|
||||
# REFRESH_TOKEN_TTL: 99d
|
||||
|
||||
persistence:
|
||||
mongo:
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
redis:
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
@@ -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
|
||||
@@ -28,4 +28,5 @@ dist-ssr
|
||||
|
||||
# Scripts
|
||||
*.sh
|
||||
!env.sh
|
||||
.env.development
|
||||
Executable
+8
@@ -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
|
||||
@@ -12,7 +12,6 @@
|
||||
/>
|
||||
|
||||
<title>Checkmate</title>
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Generated
+211
-321
File diff suppressed because it is too large.
Load diff
+3
-5
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.11.0",
|
||||
"version": "3.7.1",
|
||||
"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",
|
||||
@@ -15,10 +14,9 @@
|
||||
"format-check": "prettier --check ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
"**/*": "prettier --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
@@ -34,7 +32,7 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"human-interval": "2.0.1",
|
||||
"i18next": "25.4.2",
|
||||
"joi": "17.13.4",
|
||||
"joi": "17.13.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"mui-color-input": "^7.0.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
|
||||
"& .MuiButtonBase-root": {
|
||||
mr: theme.spacing(6),
|
||||
},
|
||||
"&:has(.Mui-focusVisible)": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: props.multiline ? "auto" : 34,
|
||||
height: 34,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
export * from "./HeaderMonitorsSummary";
|
||||
export * from "./BulkActionsBar";
|
||||
export * from "./MonitorListPage";
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -102,7 +102,6 @@ export const Sidebar = () => {
|
||||
})}
|
||||
</List>
|
||||
<StarPrompt />
|
||||
|
||||
<Divider sx={{ borderColor: theme.palette.divider }} />
|
||||
|
||||
<AuthFooter
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||
import {
|
||||
DefaultHttpMethod,
|
||||
DefaultMonitorMatchMethod,
|
||||
DefaultPageSpeedStrategy,
|
||||
DefaultProxyMode,
|
||||
} from "@/Types/Monitor";
|
||||
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
|
||||
interface UseMonitorFormOptions {
|
||||
@@ -45,15 +40,11 @@ export const getMonitorDefaults = (
|
||||
...getGeoCheckDefaults(data),
|
||||
type: "http",
|
||||
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 || [],
|
||||
};
|
||||
break;
|
||||
case "ping":
|
||||
@@ -142,15 +133,11 @@ export const getMonitorDefaults = (
|
||||
...getGeoCheckDefaults(data),
|
||||
type: "http",
|
||||
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>;
|
||||
@@ -8,17 +8,81 @@ 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 || "",
|
||||
};
|
||||
}
|
||||
// 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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -31,7 +31,6 @@ export const useStatusPageForm = ({
|
||||
const defaults: StatusPageFormData = {
|
||||
companyName: data?.companyName || "",
|
||||
url: data?.url || generateDefaultUrl(),
|
||||
customDomain: data?.customDomain ?? null,
|
||||
timezone: resolveTimezone(data?.timezone),
|
||||
type: data?.type || ["uptime"],
|
||||
color: data?.color || "#13715B",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
Loaded 100 of 672 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user