Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd520dacc5 | ||
|
|
637bb5cca9 | ||
|
|
ede93add64 | ||
|
|
4443a53a23 | ||
|
|
1b2d2887af | ||
|
|
54690fc73c | ||
|
|
6e3252aec8 | ||
|
|
edd9335e96 | ||
|
|
cf13bc329e | ||
|
|
80097afb71 | ||
|
|
5435167e73 | ||
|
|
059dc2737b | ||
|
|
158f2fffbe | ||
|
|
68c1f2e281 | ||
|
|
a719edb0fc | ||
|
|
53702ec04b | ||
|
|
84a73d9616 | ||
|
|
7b2358bd48 | ||
|
|
79957693fe | ||
|
|
7fd368def3 | ||
|
|
dd44e6c34f | ||
|
|
092f890f7f | ||
|
|
2468d3e287 | ||
|
|
bc25c15c34 | ||
|
|
1b6218ea56 | ||
|
|
6eba53d716 | ||
|
|
8bfb3a7de5 | ||
|
|
5cca0fb2d3 | ||
|
|
617ac3a9bb | ||
|
|
e92fe39e3b | ||
|
|
248dad01d4 | ||
|
|
840c936501 | ||
|
|
7096afcdf2 | ||
|
|
c15d6e0851 | ||
|
|
506133af29 | ||
|
|
03f168fa84 | ||
|
|
4b02bbec37 | ||
|
|
42aae20fa3 | ||
|
|
c71c833b77 | ||
|
|
cee7db64ee | ||
|
|
40e06a2f2b | ||
|
|
7478bd7d17 | ||
|
|
7b1d6c763f | ||
|
|
ff2e1fd648 | ||
|
|
9001a8d294 | ||
|
|
ab7f2cd8f4 | ||
|
|
c1f0bf3dd4 | ||
|
|
3fc7020a90 | ||
|
|
6f9642c298 | ||
|
|
3a22d3e819 | ||
|
|
697574a2a9 | ||
|
|
0cbd9ab8a2 | ||
|
|
9032fcd7f0 | ||
|
|
ed268b3099 | ||
|
|
e26825ef99 | ||
|
|
741ee7a92a | ||
|
|
099eaa38c3 | ||
|
|
c35b22e1ac | ||
|
|
1c9739d1da | ||
|
|
d4eaad6170 | ||
|
|
75516a220d | ||
|
|
f97ca8565a | ||
|
|
b5f8faf1e8 | ||
|
|
b088101160 | ||
|
|
521827e1d0 | ||
|
|
7dd378f085 | ||
|
|
a7fb235c56 | ||
|
|
7f68d23566 | ||
|
|
caebb93748 | ||
|
|
5640068907 | ||
|
|
441ecddf92 | ||
|
|
e5959ca91d | ||
|
|
2765ebfc3b | ||
|
|
8d1fd61bb4 | ||
|
|
865fa7cce6 | ||
|
|
2c780b8101 | ||
|
|
ee25c6b479 | ||
|
|
d0c8da1653 | ||
|
|
5916d0ca33 | ||
|
|
69db716f0b | ||
|
|
350083d179 | ||
|
|
11a6ee71e9 | ||
|
|
eaf93668f5 | ||
|
|
59267ef451 | ||
|
|
35f9381573 | ||
|
|
3df85b7522 | ||
|
|
012e09d30c | ||
|
|
4b921f4986 | ||
|
|
0eff052258 | ||
|
|
52c80b9a39 | ||
|
|
6ceb28d3a1 | ||
|
|
08bd2d56f3 | ||
|
|
05f265743b | ||
|
|
bc2115f578 | ||
|
|
c1e9fd6079 | ||
|
|
226864079c | ||
|
|
7cab326ad3 | ||
|
|
94f1f7e576 | ||
|
|
20d1d1407b | ||
|
|
da7e8a1ae8 | ||
|
|
c578387261 | ||
|
|
c5ef61e64c | ||
|
|
6e1e5dd1a5 | ||
|
|
514ee0d30b | ||
|
|
623db938f5 | ||
|
|
1c291bb893 | ||
|
|
466d356ad1 | ||
|
|
0c184e6d0c | ||
|
|
303f47a117 | ||
|
|
5fa74bd883 | ||
|
|
4c8342f379 | ||
|
|
dfcb3b4b21 | ||
|
|
2e19b85a2d | ||
|
|
5774cb0e06 | ||
|
|
750fc9e238 | ||
|
|
3646af9c04 | ||
|
|
d4782a97a3 | ||
|
|
e0122d3761 | ||
|
|
e3b0b33b15 | ||
|
|
19b5976f85 | ||
|
|
336dbe4de2 | ||
|
|
65c6f567b5 | ||
|
|
6e6ac5dbf6 | ||
|
|
e81fdb9810 | ||
|
|
9712579c4d | ||
|
|
98e0ffc983 | ||
|
|
ac251c4d3f | ||
|
|
08f167c18e | ||
|
|
8a608961e6 | ||
|
|
955c400731 | ||
|
|
093553f9ba | ||
|
|
e5282b9099 | ||
|
|
6b4444038c | ||
|
|
89464e9be4 | ||
|
|
6856914d41 | ||
|
|
c102cd8d26 | ||
|
|
ff28e74aca | ||
|
|
23b420d072 | ||
|
|
0172272dc3 | ||
|
|
3f088c70f8 | ||
|
|
ed54fa4668 | ||
|
|
0e0cace676 | ||
|
|
58f1ae7fe9 | ||
|
|
e3075318b6 | ||
|
|
81aab35961 | ||
|
|
4e495eed33 | ||
|
|
061da5e7ef | ||
|
|
a29b7037cf | ||
|
|
c7662727e7 | ||
|
|
13c921531c | ||
|
|
0cf8cfa5dc | ||
|
|
4182a964a6 | ||
|
|
04f7ef252a | ||
|
|
3201653f19 | ||
|
|
0988a61c1d | ||
|
|
80ea09b864 | ||
|
|
e0228918fb | ||
|
|
8369db5ca2 | ||
|
|
49e8d26bfc | ||
|
|
c28be97fb0 | ||
|
|
ed36db172a | ||
|
|
c021d04f53 | ||
|
|
86eeda3f0e | ||
|
|
25d81f4238 | ||
|
|
af8ee643e1 | ||
|
|
61e442493a | ||
|
|
9540084425 | ||
|
|
f50ba1bf23 | ||
|
|
b4e42bfd68 | ||
|
|
3af00281c4 | ||
|
|
4a90ab8e78 | ||
|
|
7598cf3007 | ||
|
|
e1c89cd093 | ||
|
|
80d4e818f1 | ||
|
|
4949acac1c | ||
|
|
586e79efb2 | ||
|
|
c961f5292d | ||
|
|
d53785b768 | ||
|
|
b9cbf199e6 | ||
|
|
ac6678a986 | ||
|
|
ed1ef56184 | ||
|
|
ac16126e2e | ||
|
|
8e600e9a87 | ||
|
|
790d83b7ed | ||
|
|
c567e4be5e | ||
|
|
936d9cd9a0 | ||
|
|
6e047d2cdb | ||
|
|
aad03b787f | ||
|
|
bb374db463 | ||
|
|
57e008c7ba | ||
|
|
5d3dc03c89 | ||
|
|
bf255a0178 | ||
|
|
36434c1160 | ||
|
|
63cecf3447 | ||
|
|
4d9e5821a8 | ||
|
|
5433a0cf5f | ||
|
|
d485adbb54 | ||
|
|
3eadb99c5e | ||
|
|
9c04061a04 | ||
|
|
897ffd5736 | ||
|
|
368595178d | ||
|
|
924c862244 | ||
|
|
cf451416b1 | ||
|
|
6e2a64fc9f | ||
|
|
0e52cb6231 | ||
|
|
507b96164f | ||
|
|
887521c376 | ||
|
|
caee9510eb | ||
|
|
825a81d326 | ||
|
|
bd13e9d9c8 | ||
|
|
92696d3685 | ||
|
|
5120390923 | ||
|
|
0810fcbb88 | ||
|
|
15453c2d8d | ||
|
|
70f10a73d8 | ||
|
|
930c06f5ff | ||
|
|
b088a8c878 | ||
|
|
067a186765 | ||
|
|
107b5cbd96 | ||
|
|
7fdd51c072 | ||
|
|
2331035da0 | ||
|
|
fd4edb7227 | ||
|
|
538b0df7e9 | ||
|
|
9f79feec8e | ||
|
|
fe703199f5 | ||
|
|
800080fd06 | ||
|
|
c1bcd9b206 | ||
|
|
5d55750b7b | ||
|
|
4f398e98d3 | ||
|
|
242e853bf0 | ||
|
|
523bf2253c | ||
|
|
ceacaa5499 | ||
|
|
91343f996b | ||
|
|
312961e8c9 | ||
|
|
0475f194e9 | ||
|
|
bbb051406f | ||
|
|
40a7599388 | ||
|
|
dffce2709e | ||
|
|
62031a0ab1 | ||
|
|
ef175950b2 | ||
|
|
6d91efecad | ||
|
|
f7d3bcb696 | ||
|
|
4a093e2b97 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.env
|
||||
**/*.env
|
||||
.git
|
||||
docker/dev/mongo/data
|
||||
@@ -0,0 +1,103 @@
|
||||
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,148 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,143 +0,0 @@
|
||||
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
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -35,9 +35,9 @@ npm run format # Prettier formatting
|
||||
|
||||
### Docker Development
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
+30
-25
@@ -88,34 +88,10 @@ 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:6.0
|
||||
```
|
||||
|
||||
Navigate back to the root directory:
|
||||
|
||||
```bash
|
||||
cd ../..
|
||||
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0
|
||||
```
|
||||
|
||||
#### Step 3: Set Up the Backend (Server)
|
||||
@@ -256,6 +232,35 @@ 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:
|
||||
|
||||
@@ -80,7 +80,39 @@ Usage instructions can be found [here](https://checkmate.so/docs).
|
||||
<a id="installation"></a>
|
||||
## Installation
|
||||
|
||||
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
||||
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.
|
||||
|
||||
### Configuration
|
||||
|
||||
The image is configured entirely through environment variables on the server container:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
|
||||
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
|
||||
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
|
||||
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
|
||||
|
||||
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `CLIENT_CONFIG_API_BASE_URL` | Full base URL the client calls the API at, e.g. `https://api.example.com/api/v1`; defaults to same-origin `/api/v1` |
|
||||
| `CLIENT_CONFIG_CLIENT_HOST` | Origin used when the client builds absolute links (invites, status pages); defaults to the browser's current origin |
|
||||
| `CLIENT_CONFIG_LOG_LEVEL` | Browser console log level: `error`, `warn`, `info`, or `debug` (default `error`) |
|
||||
|
||||
> **Upgrading from an older image?** The `UPTIME_APP_*` variables (`UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST`, `UPTIME_APP_LOG_LEVEL`) are no longer read. In most setups no replacement is needed — the same-origin defaults cover them; if you pointed the client at a different origin, use the `CLIENT_CONFIG_*` equivalents above. The `checkmate-client`, `checkmate-backend`, `checkmate-mongo`, and `checkmate-backend-mono-multiarch` images are no longer updated — switch to `ghcr.io/bluewave-labs/checkmate`, keeping your existing MongoDB service and data volume.
|
||||
|
||||
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
|
||||
|
||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
|
||||
@@ -121,7 +153,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, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, 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
|
||||
|
||||
@@ -3,4 +3,8 @@ name: checkmate-chart
|
||||
description: A Helm chart for Checkmate App
|
||||
type: application
|
||||
version: 0.2.0
|
||||
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
|
||||
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
|
||||
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
|
||||
# are never published, so a bumped chart will fail to pull.
|
||||
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
|
||||
@@ -1,4 +1,6 @@
|
||||
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
|
||||
# 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=
|
||||
@@ -28,5 +28,4 @@ dist-ssr
|
||||
|
||||
# Scripts
|
||||
*.sh
|
||||
!env.sh
|
||||
.env.development
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/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,6 +12,7 @@
|
||||
/>
|
||||
|
||||
<title>Checkmate</title>
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Generated
+4
-24
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.9.2",
|
||||
"version": "3.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.9.2",
|
||||
"version": "3.10.0",
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
@@ -92,7 +92,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -408,7 +407,6 @@
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -446,7 +444,6 @@
|
||||
"node_modules/@emotion/styled": {
|
||||
"version": "11.14.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1226,7 +1223,6 @@
|
||||
"node_modules/@mui/material": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.7",
|
||||
@@ -1331,7 +1327,6 @@
|
||||
"node_modules/@mui/system": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/private-theming": "^7.3.7",
|
||||
@@ -2376,7 +2371,6 @@
|
||||
"version": "24.5.2",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.12.0"
|
||||
}
|
||||
@@ -2392,7 +2386,6 @@
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2483,7 +2476,6 @@
|
||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.18.0",
|
||||
"@typescript-eslint/types": "7.18.0",
|
||||
@@ -2764,7 +2756,6 @@
|
||||
"version": "8.15.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3121,7 +3112,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
@@ -3565,8 +3555,7 @@
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3965,7 +3954,6 @@
|
||||
"version": "8.57.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4778,7 +4766,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -5590,7 +5577,6 @@
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
@@ -6372,7 +6358,6 @@
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6384,7 +6369,6 @@
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.71.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -6588,8 +6572,7 @@
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-persist": {
|
||||
"version": "6.0.0",
|
||||
@@ -6739,7 +6722,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -7581,7 +7563,6 @@
|
||||
"version": "5.9.3",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7719,7 +7700,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.9.2",
|
||||
"version": "3.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// 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 } from "@/Utils/TimeUtils";
|
||||
import { formatDateWithTz, formatMs } 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?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
||||
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
|
||||
</Typography>
|
||||
<Typography textTransform={"capitalize"}>
|
||||
Status:{" "}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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[];
|
||||
@@ -18,25 +20,25 @@ interface HistogramResponseTimeProps {
|
||||
}
|
||||
|
||||
interface ResponseTimeStats {
|
||||
max: number | string;
|
||||
avg: number | string;
|
||||
max: string | null;
|
||||
avg: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_HEIGHT = 50;
|
||||
|
||||
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
||||
if (!Array.isArray(checks) || checks.length === 0) {
|
||||
return { max: "-", avg: "-" };
|
||||
return { max: null, avg: null };
|
||||
}
|
||||
|
||||
const validChecks = checks.filter((check) => check.originalResponseTime != null);
|
||||
const validChecks = checks.filter((check) => check.responseTime != null);
|
||||
if (validChecks.length === 0) {
|
||||
return { max: "-", avg: "-" };
|
||||
return { max: null, avg: null };
|
||||
}
|
||||
|
||||
const responseTimes = validChecks.map((check) => check.originalResponseTime);
|
||||
const max = Math.round(Math.max(...responseTimes));
|
||||
const avg = Math.round(
|
||||
const responseTimes = validChecks.map((check) => check.responseTime);
|
||||
const max = formatMs(Math.max(...responseTimes));
|
||||
const avg = formatMs(
|
||||
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
||||
);
|
||||
|
||||
@@ -54,7 +56,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 =
|
||||
@@ -68,29 +70,28 @@ export const HistogramResponseTime = ({
|
||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||
const gridGap = gap ?? theme.spacing(0.5);
|
||||
|
||||
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}
|
||||
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
|
||||
>
|
||||
<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>
|
||||
);
|
||||
{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
|
||||
@@ -111,7 +112,7 @@ export const HistogramResponseTime = ({
|
||||
>
|
||||
{data.map((check, index) => {
|
||||
const isPlaceholder = (check as any).status === "placeholder";
|
||||
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
||||
const heightPct = `${barHeights[index] ?? 0}%`;
|
||||
const barColor =
|
||||
check.status === true
|
||||
? theme.palette.success.light
|
||||
|
||||
@@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
|
||||
export const BaseChart = ({
|
||||
@@ -21,16 +22,22 @@ 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
|
||||
@@ -40,6 +47,7 @@ export const BaseChart = ({
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
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?: string;
|
||||
size?: DotSize;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
||||
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
content: '""',
|
||||
width: size,
|
||||
height: size,
|
||||
width: DOT_SIZE_PX[size],
|
||||
height: DOT_SIZE_PX[size],
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
|
||||
@@ -5,13 +5,15 @@ interface IconProps {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
stroke?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: 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 {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
||||
Checking every {formatDuration(monitor?.interval, true)}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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>,
|
||||
@@ -15,6 +16,9 @@ type AutoCompleteInputProps = Omit<
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
renderOptionContent?: (option: any) => React.ReactNode;
|
||||
placeholder?: string;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
};
|
||||
|
||||
export const AutoCompleteInput = ({
|
||||
@@ -22,15 +26,21 @@ 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="Type to search"
|
||||
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ 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,6 +42,9 @@ export const RadioWithDescription = ({
|
||||
"& .MuiButtonBase-root": {
|
||||
mr: theme.spacing(6),
|
||||
},
|
||||
"&:has(.Mui-focusVisible)": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -10,11 +11,15 @@ 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();
|
||||
@@ -33,12 +38,12 @@ export const TimePickerComponent = ({
|
||||
px: theme.spacing(5),
|
||||
},
|
||||
"& fieldset": {
|
||||
borderColor: theme.palette.divider,
|
||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: theme.palette.divider,
|
||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -48,14 +53,18 @@ export const TimePickerComponent = ({
|
||||
</LocalizationProvider>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{picker}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return picker;
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
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 ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
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 ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,187 @@
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
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";
|
||||
export interface FormRadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface FormRadioGroupProps<T extends FieldValues> {
|
||||
name: FieldPath<T>;
|
||||
options: FormRadioOption[];
|
||||
}
|
||||
export const FormRadioGroup = <T extends FieldValues>({
|
||||
name,
|
||||
options,
|
||||
}: FormRadioGroupProps<T>) => {
|
||||
const { control } = useFormContext<T>();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormControl error={!!fieldState.error}>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
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}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
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,15 +18,13 @@ const RootLayout = () => {
|
||||
flex={1}
|
||||
padding={6}
|
||||
overflow={"hidden"}
|
||||
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,
|
||||
}}
|
||||
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}
|
||||
>
|
||||
<Stack
|
||||
maxWidth={1280}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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[];
|
||||
@@ -83,10 +84,6 @@ 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}
|
||||
@@ -186,7 +183,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
>
|
||||
Response Time:
|
||||
</Typography>{" "}
|
||||
{formatResponseTime(selectedCheck.timings.total)}
|
||||
{formatMs(selectedCheck.timings.total)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
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();
|
||||
@@ -31,14 +33,9 @@ export const MonitorStatBoxes = ({
|
||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
||||
|
||||
const options = {
|
||||
secondsDecimalDigits: 0,
|
||||
millisecondsDecimalDigits: 0,
|
||||
};
|
||||
const streakTime = formatDuration(timeSinceLastFailure);
|
||||
|
||||
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
|
||||
|
||||
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
|
||||
const lastCheckTime = formatDuration(timeSinceLastCheck);
|
||||
const isActive =
|
||||
monitor?.status === "up" ||
|
||||
monitor?.status === "paused" ||
|
||||
@@ -67,14 +64,20 @@ export const MonitorStatBoxes = ({
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
||||
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
|
||||
subtitle={formatMs(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.certificateExpiry")}
|
||||
subtitle={certificateExpiry || "N/A"}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.domainExpiry")}
|
||||
subtitle={domainExpiry || "N/A"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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,53 +1,39 @@
|
||||
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 {
|
||||
formatDateWithTz,
|
||||
tickDateFormatLookup,
|
||||
tooltipDateFormatLookup,
|
||||
} from "@/Utils/TimeUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import type { GroupedCheck } from "@/Types/Check";
|
||||
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
|
||||
|
||||
type XTickProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: string };
|
||||
range: string;
|
||||
};
|
||||
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";
|
||||
|
||||
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 DetailsCheck = GroupedCheck &
|
||||
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
|
||||
|
||||
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||
range: string;
|
||||
@@ -63,17 +49,65 @@ const ResponseTimeToolTip = ({
|
||||
theme,
|
||||
uiTimezone,
|
||||
}: ResponseTimeToolTipProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!label) return null;
|
||||
if (!payload) return null;
|
||||
if (!active) return null;
|
||||
|
||||
const format = tooltipDateFormatLookup(range);
|
||||
console.log(payload);
|
||||
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
||||
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
|
||||
return (
|
||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||
<Typography>Response time: {responseTime} ms</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>
|
||||
))}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -82,11 +116,28 @@ export const HistogramDetails = ({
|
||||
checks,
|
||||
range,
|
||||
}: {
|
||||
checks: GroupedCheck[];
|
||||
checks: DetailsCheck[];
|
||||
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={
|
||||
@@ -95,7 +146,15 @@ export const HistogramDetails = ({
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
title="Response times"
|
||||
title={
|
||||
showTimingPhases
|
||||
? t("common.charts.labels.timingPhases")
|
||||
: t("common.charts.labels.responseTime")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!hasTimingPhases) return;
|
||||
setShowTimingPhases((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
@@ -128,7 +187,34 @@ 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}
|
||||
@@ -141,24 +227,99 @@ export const HistogramDetails = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={(props) => (
|
||||
<ResponseTimeToolTip
|
||||
{...props}
|
||||
range={range}
|
||||
theme={theme}
|
||||
uiTimezone={uiTimezone}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="avgResponseTime"
|
||||
stroke={theme.palette.primary.main}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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";
|
||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||
|
||||
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";
|
||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||
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 } from "@/Utils/TimeUtils";
|
||||
import { formatDateWithTz, formatMs } 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">{Math.floor(avg)} ms</Typography>
|
||||
<Typography variant="body2">{formatMs(avg)}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ 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();
|
||||
@@ -20,7 +21,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
|
||||
const msg: Record<string, string> = {
|
||||
success: "Excellent",
|
||||
warning: "Average",
|
||||
danger: "Poor",
|
||||
error: "Poor",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,7 +85,9 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
|
||||
<Typography
|
||||
variant="h1"
|
||||
textAlign={"center"}
|
||||
>{`${avg?.toFixed()}ms`}</Typography>
|
||||
>
|
||||
{formatMs(avg)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BaseChart>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -10,10 +10,11 @@ import { useSelector, useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { MoreVertical, LogOut } from "lucide-react";
|
||||
import { MoreVertical, LogOut, Moon, Sun } 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;
|
||||
@@ -27,6 +28,7 @@ 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();
|
||||
@@ -105,13 +107,14 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
||||
<Typography
|
||||
fontWeight={500}
|
||||
noWrap
|
||||
sx={{ color: theme.palette.text.primary }}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{user?.firstName} {user?.lastName}
|
||||
</Typography>
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
|
||||
textTransform={"capitalize"}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{getRoleText()}
|
||||
</Typography>
|
||||
@@ -154,15 +157,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
||||
pb={2}
|
||||
sx={{ pointerEvents: "none" }}
|
||||
>
|
||||
<Typography
|
||||
fontWeight={500}
|
||||
fontSize={13}
|
||||
>
|
||||
<Typography fontWeight={500}>
|
||||
{user?.firstName} {user?.lastName}
|
||||
</Typography>
|
||||
<Typography
|
||||
fontSize={12}
|
||||
sx={{ textTransform: "capitalize" }}
|
||||
variant="body2"
|
||||
textTransform={"capitalize"}
|
||||
>
|
||||
{getRoleText()}
|
||||
</Typography>
|
||||
@@ -178,6 +178,18 @@ 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}
|
||||
|
||||
@@ -102,6 +102,7 @@ export const Sidebar = () => {
|
||||
})}
|
||||
</List>
|
||||
<StarPrompt />
|
||||
|
||||
<Divider sx={{ borderColor: theme.palette.divider }} />
|
||||
|
||||
<AuthFooter
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||
import {
|
||||
DefaultHttpMethod,
|
||||
DefaultMonitorMatchMethod,
|
||||
DefaultPageSpeedStrategy,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
|
||||
interface UseMonitorFormOptions {
|
||||
@@ -43,7 +47,7 @@ export const getMonitorDefaults = (
|
||||
method: data?.method ?? DefaultHttpMethod,
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
useAdvancedMatching: data?.useAdvancedMatching || false,
|
||||
matchMethod: data?.matchMethod || "",
|
||||
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
|
||||
expectedValue: data?.expectedValue || "",
|
||||
jsonPath: data?.jsonPath || "",
|
||||
customUpCodes: data?.customUpCodes || [],
|
||||
@@ -138,7 +142,7 @@ export const getMonitorDefaults = (
|
||||
method: DefaultHttpMethod,
|
||||
ignoreTlsErrors: false,
|
||||
useAdvancedMatching: false,
|
||||
matchMethod: "",
|
||||
matchMethod: DefaultMonitorMatchMethod,
|
||||
expectedValue: "",
|
||||
jsonPath: "",
|
||||
customUpCodes: [],
|
||||
|
||||
@@ -8,89 +8,17 @@ interface UseNotificationFormOptions {
|
||||
}
|
||||
|
||||
function buildDefaults(data: Notification | null): NotificationFormData {
|
||||
if (data?.type === "matrix") {
|
||||
return {
|
||||
type: "matrix",
|
||||
notificationName: data.notificationName || "",
|
||||
homeserverUrl: data.homeserverUrl || "",
|
||||
roomId: data.roomId || "",
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "telegram") {
|
||||
return {
|
||||
type: "telegram",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "slack") {
|
||||
return {
|
||||
type: "slack",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "discord") {
|
||||
return {
|
||||
type: "discord",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "webhook") {
|
||||
return {
|
||||
type: "webhook",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "pager_duty") {
|
||||
return {
|
||||
type: "pager_duty",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "teams") {
|
||||
return {
|
||||
type: "teams",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "twilio") {
|
||||
return {
|
||||
type: "twilio",
|
||||
notificationName: data.notificationName || "",
|
||||
accountSid: data.accountSid || "",
|
||||
accessToken: data.accessToken || "",
|
||||
phone: data.phone || "",
|
||||
twilioPhoneNumber: data.twilioPhoneNumber || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "pushover") {
|
||||
return {
|
||||
type: "pushover",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
accessToken: data.accessToken || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "ntfy") {
|
||||
return {
|
||||
type: "ntfy",
|
||||
notificationName: data.notificationName || "",
|
||||
address: data.address || "",
|
||||
topic: data.topic || "",
|
||||
};
|
||||
}
|
||||
// Default: email (covers both data === null and data.type === "email")
|
||||
return {
|
||||
type: "email",
|
||||
type: data?.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,11 +1,6 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { TextField, Button, Autocomplete } from "@/Components/inputs";
|
||||
import { TextField, Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
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 { FormProvider, useForm } from "react-hook-form";
|
||||
import { ConfigBox, BasePage } from "@/Components/design-elements";
|
||||
|
||||
import { UserRoles } from "@/Types/User";
|
||||
@@ -22,6 +17,8 @@ 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;
|
||||
@@ -43,10 +40,11 @@ const EditUserPage = () => {
|
||||
|
||||
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
|
||||
const form = useForm<EditUserFormData>({
|
||||
resolver,
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const { handleSubmit, reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -64,13 +62,6 @@ 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 &&
|
||||
@@ -90,42 +81,31 @@ const EditUserPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage loading={isLoading}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
component={"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)}>
|
||||
<Controller
|
||||
<FormTextField
|
||||
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}
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
)}
|
||||
autoComplete="given-name"
|
||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||
/>
|
||||
<Controller
|
||||
<FormTextField
|
||||
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}
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
)}
|
||||
autoComplete="family-name"
|
||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fieldLabel={t("common.form.email.option.email.label")}
|
||||
placeholder={t("common.form.email.option.email.placeholder")}
|
||||
@@ -139,55 +119,11 @@ const EditUserPage = () => {
|
||||
title={t("pages.editUser.form.roles.title")}
|
||||
subtitle={t("pages.editUser.form.roles.description")}
|
||||
rightContent={
|
||||
<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>
|
||||
<FormMultiSelectField
|
||||
name="role"
|
||||
fieldLabel={t("common.form.role.option.role.label")}
|
||||
options={roleOptions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
@@ -217,19 +153,19 @@ const EditUserPage = () => {
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import { ConfigBox } from "@/Components/design-elements";
|
||||
import { TextField, Button } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } 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();
|
||||
@@ -15,10 +17,11 @@ export const TabPassword = () => {
|
||||
const { resolver, defaults } = usePasswordForm();
|
||||
const { patch, loading } = usePatch<FormData, void>();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<PasswordFormData>({
|
||||
const form = useForm<PasswordFormData>({
|
||||
resolver,
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const { handleSubmit, reset } = form;
|
||||
|
||||
const onSubmit = async (data: PasswordFormData) => {
|
||||
const fd = new FormData();
|
||||
@@ -32,85 +35,61 @@ export const TabPassword = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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 }}
|
||||
<FormProvider {...form}>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<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")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import { ConfigBox } from "@/Components/design-elements";
|
||||
import { TextField, Button } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { ImageUpload } from "@/Components/inputs";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useProfileForm } from "@/Hooks/useProfileForm";
|
||||
import { usePatch } from "@/Hooks/UseApi";
|
||||
@@ -13,6 +13,8 @@ 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();
|
||||
@@ -22,7 +24,7 @@ export const TabProfile = () => {
|
||||
const { resolver, defaults } = useProfileForm();
|
||||
const { patch, loading: patchLoading } = usePatch<FormData, User>();
|
||||
|
||||
const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
|
||||
const form = useForm<ProfileFormData>({
|
||||
resolver,
|
||||
defaultValues: {
|
||||
firstName: user?.firstName ?? defaults.firstName,
|
||||
@@ -31,7 +33,7 @@ export const TabProfile = () => {
|
||||
deleteProfileImage: defaults.deleteProfileImage,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, setValue, watch } = form;
|
||||
const currentImage = watch("profileImage");
|
||||
const deleteImage = watch("deleteProfileImage");
|
||||
|
||||
@@ -74,66 +76,52 @@ export const TabProfile = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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 }}
|
||||
<FormProvider {...form}>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
loading={patchLoading}
|
||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||
import { Dialog, TextField, Select } from "@/Components/inputs";
|
||||
import { Dialog } from "@/Components/inputs";
|
||||
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
|
||||
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
|
||||
import type { UserRole, User } from "@/Types/User";
|
||||
import type { 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;
|
||||
@@ -35,17 +36,13 @@ export const AddTeamMemberDialog = ({
|
||||
const { schema, defaults } = useAddTeamMemberForm();
|
||||
const { post, loading } = usePost<RegisterPayload, User>();
|
||||
|
||||
const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
|
||||
const form = useForm<AddTeamMemberFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
values: defaults,
|
||||
});
|
||||
|
||||
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 { handleSubmit, reset, setError } = form;
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
@@ -55,7 +52,7 @@ export const AddTeamMemberDialog = ({
|
||||
const onSubmit = async (data: AddTeamMemberFormData) => {
|
||||
if (loading) return;
|
||||
|
||||
const { confirm, ...userData } = data;
|
||||
const { confirm: _confirm, ...userData } = data;
|
||||
const payload: RegisterPayload = userData;
|
||||
|
||||
const result = await post("/auth/users", payload);
|
||||
@@ -72,124 +69,64 @@ export const AddTeamMemberDialog = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<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 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<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
|
||||
<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")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<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 }}
|
||||
/>
|
||||
)}
|
||||
<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")}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useTheme, FormHelperText, Typography } from "@mui/material";
|
||||
import { useTheme, Typography } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { Dialog, TextField, Select, Button } from "@/Components/inputs";
|
||||
import type { UserRole } from "@/Types/User";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { Dialog, TextField, Button } from "@/Components/inputs";
|
||||
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 = import.meta.env.VITE_APP_CLIENT_HOST;
|
||||
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
|
||||
|
||||
interface InviteResponse {
|
||||
token: string;
|
||||
@@ -30,11 +31,12 @@ export const InviteTeamMemberDialog = ({
|
||||
const { t } = useTranslation();
|
||||
const { resolver, defaults } = useInviteForm();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<InviteFormData>({
|
||||
const form = useForm<InviteFormData>({
|
||||
resolver,
|
||||
defaultValues: defaults,
|
||||
values: defaults,
|
||||
});
|
||||
const { handleSubmit, reset } = form;
|
||||
|
||||
const { post: generateToken, loading: generateLoading } = usePost<
|
||||
InviteFormData,
|
||||
@@ -46,17 +48,11 @@ 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 ? `${CLIENT_HOST}/register/${token}` : token;
|
||||
const link = `${CLIENT_HOST || window.location.origin}/register/${token}`;
|
||||
setInviteLink(link);
|
||||
}
|
||||
};
|
||||
@@ -75,88 +71,55 @@ export const InviteTeamMemberDialog = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<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 }) => (
|
||||
<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 && (
|
||||
<>
|
||||
<Select
|
||||
{...field}
|
||||
value={Array.isArray(value) ? (value[0] ?? "") : ""}
|
||||
onChange={(e) => onChange([e.target.value as UserRole])}
|
||||
fieldLabel={t("common.form.role.option.role.label")}
|
||||
<Typography variant="body2">
|
||||
{t("pages.account.team.invite.linkLabel")}
|
||||
</Typography>
|
||||
<TextField
|
||||
value={inviteLink}
|
||||
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>
|
||||
)}
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{inviteLink && (
|
||||
<>
|
||||
<Typography variant="body2">
|
||||
{t("pages.account.team.invite.linkLabel")}
|
||||
</Typography>
|
||||
<TextField
|
||||
value={inviteLink}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
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, TextField } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } 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,6 +11,7 @@ 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();
|
||||
@@ -31,11 +32,13 @@ const LoginPage = () => {
|
||||
|
||||
const { schema, defaults } = useLoginForm();
|
||||
|
||||
const { control, handleSubmit } = useForm<LoginFormData>({
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { handleSubmit } = form;
|
||||
|
||||
if (isCheckingAdmin) return null;
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
@@ -50,53 +53,39 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
<FormProvider {...form}>
|
||||
<BaseAuthPage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
title={t("pages.auth.login.title")}
|
||||
subtitle={t("pages.auth.login.subtitle")}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Button, TextField } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
||||
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } 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();
|
||||
@@ -14,10 +15,11 @@ const ForgotPasswordPage = () => {
|
||||
|
||||
const { schema, defaults } = useRecoveryForm();
|
||||
|
||||
const { control, handleSubmit } = useForm<RecoveryFormData>({
|
||||
const form = useForm<RecoveryFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const { handleSubmit } = form;
|
||||
|
||||
const onSubmit = async (data: RecoveryFormData) => {
|
||||
if (loading) return;
|
||||
@@ -30,39 +32,33 @@ const ForgotPasswordPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
<FormProvider {...form}>
|
||||
<BaseAuthPage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
title={t("pages.auth.forgotPassword.title")}
|
||||
subtitle={t("pages.auth.forgotPassword.subtitle")}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseAuthPage } from "@/Components/design-elements";
|
||||
import { Button, TextField } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } 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,6 +12,7 @@ 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">;
|
||||
@@ -45,10 +46,11 @@ const RegisterPage = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
|
||||
const form = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
const { handleSubmit, setError, reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || hasVerified.current) return;
|
||||
@@ -64,14 +66,14 @@ const RegisterPage = () => {
|
||||
navigate("/register", { replace: true });
|
||||
}
|
||||
});
|
||||
}, [token]);
|
||||
}, [token, defaults, navigate, reset, verifyToken]);
|
||||
|
||||
if (isCheckingAdmin) return null;
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
if (loading) return;
|
||||
|
||||
const { confirm, ...userData } = data;
|
||||
const { confirm: _confirm, ...userData } = data;
|
||||
const payload: RegisterPayload = { user: userData };
|
||||
if (token) {
|
||||
payload.token = token;
|
||||
@@ -89,105 +91,70 @@ const RegisterPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<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}
|
||||
<FormProvider {...form}>
|
||||
<BaseAuthPage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
title={t("pages.auth.register.title")}
|
||||
subtitle={t("pages.auth.register.subtitle")}
|
||||
>
|
||||
{t("pages.auth.register.submit")}
|
||||
</Button>
|
||||
</BaseAuthPage>
|
||||
{!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}
|
||||
>
|
||||
{t("pages.auth.register.submit")}
|
||||
</Button>
|
||||
</BaseAuthPage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
|
||||
import { Button, TextField } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } 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,6 +12,7 @@ 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();
|
||||
@@ -23,11 +24,11 @@ const SetNewPasswordPage = () => {
|
||||
|
||||
const { schema, defaults } = useSetNewPasswordForm();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
|
||||
const form = useForm<SetNewPasswordFormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { handleSubmit, watch } = form;
|
||||
const password = watch("password");
|
||||
const confirm = watch("confirm");
|
||||
|
||||
@@ -57,81 +58,68 @@ const SetNewPasswordPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
<FormProvider {...form}>
|
||||
<BaseAuthPage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
title={t("pages.auth.setNewPassword.title")}
|
||||
subtitle={t("pages.auth.setNewPassword.subtitle")}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
|
||||
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
|
||||
|
||||
@@ -24,8 +24,5 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
|
||||
return "-";
|
||||
}
|
||||
|
||||
return prettyMilliseconds(durationMs, {
|
||||
secondsDecimalDigits: 0,
|
||||
millisecondsDecimalDigits: 0,
|
||||
});
|
||||
return formatDuration(durationMs);
|
||||
};
|
||||
@@ -25,10 +25,11 @@ export const InfraDetailsGauges = ({
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
spacing={theme.spacing(8)}
|
||||
alignItems={"center"}
|
||||
alignItems={"stretch"}
|
||||
>
|
||||
<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)}
|
||||
@@ -37,6 +38,7 @@ 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)}
|
||||
@@ -49,6 +51,7 @@ export const InfraDetailsGauges = ({
|
||||
key={disk?.device || 0 + idx}
|
||||
// title={`Disk ${idx} usage`}
|
||||
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
||||
maxWidth={260}
|
||||
progress={(disk.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
||||
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
||||
|
||||
@@ -11,7 +11,8 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const latestCheck = monitor?.recentChecks?.[0];
|
||||
const recentChecks = monitor?.recentChecks ?? [];
|
||||
const latestCheck = recentChecks[recentChecks.length - 1];
|
||||
// Get data from latest check
|
||||
const physicalCores = getCores(latestCheck?.cpu?.physical_core);
|
||||
const logicalCores = getCores(latestCheck?.cpu?.logical_core);
|
||||
@@ -21,13 +22,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
|
||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||
const diskTotalBytes =
|
||||
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
|
||||
const os = getOsAndPlatform(latestCheck?.host);
|
||||
|
||||
const platform = latestCheck?.host?.platform ?? undefined;
|
||||
const osPlatform =
|
||||
typeof os === "undefined" && typeof platform === "undefined"
|
||||
? undefined
|
||||
: `${os} ${platform}`;
|
||||
const osPlatform = getOsAndPlatform(latestCheck?.host);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@@ -21,10 +21,11 @@ export const TabOverview = ({
|
||||
}
|
||||
|
||||
const checks = stats?.checks || [];
|
||||
const recentChecks = monitor.recentChecks ?? [];
|
||||
return (
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<StatusBoxes monitor={monitor} />
|
||||
<InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
|
||||
<InfraDetailsGauges snapshot={recentChecks[recentChecks.length - 1]} />
|
||||
<InfraDetailsCharts
|
||||
checks={checks}
|
||||
dateRange={dateRange}
|
||||
|
||||
@@ -248,7 +248,8 @@ export const InfraMonitorsTable = ({
|
||||
id: "cpu",
|
||||
content: t("pages.infrastructure.table.headers.cpu"),
|
||||
render: (row) => {
|
||||
const check = row.recentChecks?.[0];
|
||||
const recentChecks = row.recentChecks ?? [];
|
||||
const check = recentChecks[recentChecks.length - 1];
|
||||
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
|
||||
return <Gauge progress={cpuUsage} />;
|
||||
},
|
||||
@@ -257,7 +258,8 @@ export const InfraMonitorsTable = ({
|
||||
id: "memory",
|
||||
content: t("pages.infrastructure.table.headers.memory"),
|
||||
render: (row) => {
|
||||
const check = row.recentChecks?.[0];
|
||||
const recentChecks = row.recentChecks ?? [];
|
||||
const check = recentChecks[recentChecks.length - 1];
|
||||
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
|
||||
return <Gauge progress={memoryUsage} />;
|
||||
},
|
||||
@@ -266,7 +268,8 @@ export const InfraMonitorsTable = ({
|
||||
id: "disk",
|
||||
content: t("pages.infrastructure.table.headers.disk"),
|
||||
render: (row) => {
|
||||
const check = row.recentChecks?.[0];
|
||||
const recentChecks = row.recentChecks ?? [];
|
||||
const check = recentChecks[recentChecks.length - 1];
|
||||
|
||||
const totalDiskUsage = check?.disk?.reduce(
|
||||
(acc, disk) => acc + (disk?.usage_percent || 0),
|
||||
|
||||
@@ -2,10 +2,10 @@ import Box from "@mui/material/Box";
|
||||
import { StatBox } from "@/Components/design-elements";
|
||||
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
||||
|
||||
interface StatsProps {
|
||||
diagnostics: Diagnostics | null;
|
||||
@@ -18,13 +18,9 @@ export const Stats = ({ diagnostics }: StatsProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const eventLoopDelay = diagnostics
|
||||
? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
|
||||
millisecondsDecimalDigits: 2,
|
||||
})
|
||||
: PLACEHOLDER;
|
||||
const uptime = diagnostics
|
||||
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
|
||||
? formatMs(diagnostics.eventLoopDelayMs ?? 0)
|
||||
: PLACEHOLDER;
|
||||
const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER;
|
||||
const usedHeap = diagnostics
|
||||
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
|
||||
: PLACEHOLDER;
|
||||
@@ -37,10 +33,10 @@ export const Stats = ({ diagnostics }: StatsProps) => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
gap={theme.spacing(8)}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
|
||||
gap: theme.spacing(8),
|
||||
"& > *": { width: "100% !important" },
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Table, Pagination } from "@/Components/design-elements";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
|
||||
@@ -84,7 +83,7 @@ export const TableJobs = ({
|
||||
id: "interval",
|
||||
content: t("common.table.headers.interval"),
|
||||
render: (row) => (
|
||||
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
||||
<Typography sx={cellSx}>{formatDuration(row.monitorInterval ?? 0)}</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Pagination } from "@/Components/design-elements/Table";
|
||||
import { ActionsMenu } from "@/Components/actions-menu";
|
||||
import { DialogInput } from "@/Components/inputs/Dialog";
|
||||
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { useTheme } from "@mui/material";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
@@ -18,6 +17,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice";
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useDelete, usePatch } from "@/Hooks/UseApi";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
|
||||
interface MaintenanceWindowTableProps {
|
||||
maintenanceWindows: MaintenanceWindow[];
|
||||
@@ -51,7 +51,7 @@ const getTimeToNextWindow = (
|
||||
|
||||
// Window is in the future
|
||||
if (start.isAfter(now)) {
|
||||
return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
|
||||
return formatDuration(start.diff(now));
|
||||
}
|
||||
|
||||
return "N/A";
|
||||
@@ -152,9 +152,7 @@ export const MaintenanceWindowTable = ({
|
||||
id: "repeat",
|
||||
content: t("pages.maintenanceWindow.table.headers.repeat"),
|
||||
render: (row) =>
|
||||
row.repeat === 0
|
||||
? t("common.labels.na")
|
||||
: prettyMilliseconds(row.repeat, { verbose: true }),
|
||||
row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import {
|
||||
TextField,
|
||||
Select,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Button,
|
||||
Autocomplete,
|
||||
} from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,10 +15,17 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import { mutate } from "swr";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||
import {
|
||||
FormDatePickerField,
|
||||
FormTimePickerField,
|
||||
} from "@/Components/inputs/forms/FormDateTimePickerField";
|
||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||
|
||||
const CreateMaintenanceWindowPage = () => {
|
||||
const theme = useTheme();
|
||||
@@ -57,7 +54,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { control, handleSubmit, trigger } = form;
|
||||
const { handleSubmit, trigger } = form;
|
||||
useEffect(() => {
|
||||
if (existingMaintenanceWindow) {
|
||||
form.reset(defaults);
|
||||
@@ -114,255 +111,117 @@ const CreateMaintenanceWindowPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
component={"form"}
|
||||
onSubmit={handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.general.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.general.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
defaultValue={defaults.name}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.maintenanceWindow.form.general.option.name.placeholder"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="repeat"
|
||||
control={control}
|
||||
defaultValue={defaults.repeat}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.general.option.repeat.label"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
onChange={field.onChange}
|
||||
>
|
||||
{repeatOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
>
|
||||
<Typography>{option.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startDate.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
defaultValue={defaults.startDate}
|
||||
render={({ field, fieldState }) => (
|
||||
<DatePicker
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startDate.option.startDate.label"
|
||||
)}
|
||||
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
|
||||
onChange={(date) => {
|
||||
field.onChange(date ? date.format("YYYY-MM-DD") : "");
|
||||
trigger("startDate");
|
||||
}}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startTime.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="startTime"
|
||||
control={control}
|
||||
defaultValue={defaults.startTime}
|
||||
render={({ field }) => (
|
||||
<TimePicker
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.option.startTime.label"
|
||||
)}
|
||||
value={field.value ? dayjs(field.value, "HH:mm") : null}
|
||||
onChange={(time) => {
|
||||
field.onChange(time ? time.format("HH:mm") : "");
|
||||
trigger("startDate");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Controller
|
||||
name="duration"
|
||||
control={control}
|
||||
defaultValue={defaults.duration}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.option.duration.label"
|
||||
)}
|
||||
value={field.value === 0 ? "" : field.value}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
field.onChange(val === "" ? 0 : Number(val));
|
||||
}}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
component={"form"}
|
||||
onSubmit={handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.general.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.general.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.maintenanceWindow.form.general.option.name.placeholder"
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="durationUnit"
|
||||
control={control}
|
||||
defaultValue={defaults.durationUnit}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
fieldLabel=" "
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
{durationUnitOptions.map((option) => (
|
||||
<MenuItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
>
|
||||
<Typography>{option.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
<FormSelectField
|
||||
name="repeat"
|
||||
fieldLabel={t("pages.maintenanceWindow.form.general.option.repeat.label")}
|
||||
options={repeatOptions.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="monitors"
|
||||
control={control}
|
||||
defaultValue={defaults.monitors}
|
||||
render={({ field, fieldState }) => {
|
||||
const monitorsList = monitors ?? [];
|
||||
const selectedMonitors = field.value
|
||||
.map((id: string) => monitorsList.find((m) => m.id === id))
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitorsList}
|
||||
getOptionLabel={(option: Monitor) => option.name}
|
||||
value={selectedMonitors}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={
|
||||
selectedMonitors.length === 0
|
||||
? t(
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{selectedMonitors.length > 0 && (
|
||||
<Stack>
|
||||
{selectedMonitors.map((monitor) => (
|
||||
<Stack
|
||||
key={monitor.id}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
marginTop={theme.spacing(SPACING.LG)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Typography flexGrow={1}>{monitor.name}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value.filter((id: string) => id !== monitor.id)
|
||||
);
|
||||
}}
|
||||
aria-label="Remove monitor"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startDate.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormDatePickerField
|
||||
name="startDate"
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startDate.option.startDate.label"
|
||||
)}
|
||||
onValueChange={() => trigger("startDate")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startTime.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormTimePickerField
|
||||
name="startTime"
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.option.startTime.label"
|
||||
)}
|
||||
onValueChange={() => trigger("startDate")}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
spacing={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<FormNumberField
|
||||
name="duration"
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.option.duration.label"
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
<FormSelectField
|
||||
name="durationUnit"
|
||||
fieldLabel={" "}
|
||||
options={durationUnitOptions.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
|
||||
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
|
||||
rightContent={
|
||||
<FormMultiSelectField
|
||||
name="monitors"
|
||||
fieldLabel={t(
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)}
|
||||
options={monitors ?? []}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import { TextField, Select, Button } from "@/Components/inputs";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import { useNotificationForm } from "@/Hooks/useNotificationForm";
|
||||
@@ -16,6 +14,8 @@ import type { NotificationFormData } from "@/Validation/notifications";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotificationChannels } from "@/Types/Notification";
|
||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||
|
||||
const NotificationsCreatePage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -39,7 +39,7 @@ const NotificationsCreatePage = () => {
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
|
||||
const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaults);
|
||||
@@ -93,384 +93,195 @@ const NotificationsCreatePage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.notificationName.title")}
|
||||
subtitle={t("pages.notifications.form.notificationName.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="notificationName"
|
||||
control={control}
|
||||
defaultValue={defaults.notificationName}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
|
||||
placeholder={t("pages.notifications.form.notificationName.placeholder")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.type.title")}
|
||||
subtitle={t("pages.notifications.form.type.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
defaultValue={defaults.type}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
fieldLabel={t("pages.notifications.form.type.optionType")}
|
||||
error={!!fieldState.error}
|
||||
onChange={field.onChange}
|
||||
>
|
||||
{NotificationChannels.map((type: string) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
<Typography textTransform="capitalize">{type}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "twilio" &&
|
||||
watchedType !== "ntfy" && (
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.notificationName.title")}
|
||||
subtitle={t("pages.notifications.form.notificationName.description")}
|
||||
rightContent={
|
||||
<FormTextField
|
||||
name="notificationName"
|
||||
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
|
||||
placeholder={t("pages.notifications.form.notificationName.placeholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.type.title")}
|
||||
subtitle={t("pages.notifications.form.type.description")}
|
||||
rightContent={
|
||||
<FormSelectField
|
||||
name="type"
|
||||
fieldLabel={t("pages.notifications.form.type.optionType")}
|
||||
options={NotificationChannels.map((channel) => ({
|
||||
value: channel,
|
||||
label: t(`pages.notifications.form.type.value.${channel}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "twilio" &&
|
||||
watchedType !== "ntfy" && (
|
||||
<ConfigBox
|
||||
title={addressConfig.title}
|
||||
subtitle={addressConfig.description}
|
||||
rightContent={
|
||||
<FormTextField
|
||||
name="address"
|
||||
fieldLabel={addressConfig.fieldLabel}
|
||||
placeholder={addressConfig.placeholder}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "ntfy" && (
|
||||
<ConfigBox
|
||||
title={addressConfig.title}
|
||||
subtitle={addressConfig.description}
|
||||
title={t("pages.notifications.form.ntfy.title")}
|
||||
subtitle={t("pages.notifications.form.ntfy.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={addressConfig.fieldLabel}
|
||||
placeholder={addressConfig.placeholder}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<FormTextField
|
||||
name="address"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
|
||||
/>
|
||||
<FormTextField
|
||||
name="topic"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "ntfy" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.ntfy.title")}
|
||||
subtitle={t("pages.notifications.form.ntfy.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="topic"
|
||||
control={control}
|
||||
defaultValue={"topic" in defaults ? defaults.topic : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
|
||||
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "telegram" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.telegram.title")}
|
||||
subtitle={t("pages.notifications.form.telegram.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.telegram.placeholderBotToken"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
|
||||
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "pushover" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.pushover.title")}
|
||||
subtitle={t("pages.notifications.form.pushover.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.pushover.placeholderAppToken"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.pushover.placeholderUserKey"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "twilio" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.twilio.title")}
|
||||
subtitle={t("pages.notifications.form.twilio.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="accountSid"
|
||||
control={control}
|
||||
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.twilio.placeholderAccountSid"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.twilio.placeholderAuthToken"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="twilioPhoneNumber"
|
||||
control={control}
|
||||
defaultValue={
|
||||
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
|
||||
}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.twilio.placeholderFromNumber"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="phone"
|
||||
control={control}
|
||||
defaultValue={"phone" in defaults ? defaults.phone : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
|
||||
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "matrix" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.matrix.title")}
|
||||
subtitle={t("pages.notifications.form.matrix.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="homeserverUrl"
|
||||
control={control}
|
||||
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
|
||||
placeholder={t("pages.notifications.form.homeServer.placeholder")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="roomId"
|
||||
control={control}
|
||||
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
|
||||
placeholder={t("pages.notifications.form.roomId.placeholder")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"pages.notifications.form.accessToken.optionAccessToken"
|
||||
)}
|
||||
placeholder={t("pages.notifications.form.accessToken.placeholder")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
{watchedType === "telegram" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.telegram.title")}
|
||||
subtitle={t("pages.notifications.form.telegram.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<FormTextField
|
||||
name="accessToken"
|
||||
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
|
||||
placeholder={t("pages.notifications.form.telegram.placeholderBotToken")}
|
||||
/>
|
||||
<FormTextField
|
||||
name="address"
|
||||
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
|
||||
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "pushover" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.pushover.title")}
|
||||
subtitle={t("pages.notifications.form.pushover.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<FormTextField
|
||||
name="accessToken"
|
||||
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
|
||||
placeholder={t("pages.notifications.form.pushover.placeholderAppToken")}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name="address"
|
||||
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
|
||||
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "twilio" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.twilio.title")}
|
||||
subtitle={t("pages.notifications.form.twilio.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<FormTextField
|
||||
name="accountSid"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
|
||||
placeholder={t("pages.notifications.form.twilio.placeholderAccountSid")}
|
||||
/>
|
||||
<FormTextField
|
||||
name="accessToken"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
|
||||
placeholder={t("pages.notifications.form.twilio.placeholderAuthToken")}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name="twilioPhoneNumber"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
|
||||
placeholder={t("pages.notifications.form.twilio.placeholderFromNumber")}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name="phone"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
|
||||
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "matrix" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.matrix.title")}
|
||||
subtitle={t("pages.notifications.form.matrix.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<FormTextField
|
||||
name="homeserverUrl"
|
||||
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
|
||||
placeholder={t("pages.notifications.form.homeServer.placeholder")}
|
||||
/>
|
||||
<FormTextField
|
||||
name="roomId"
|
||||
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
|
||||
placeholder={t("pages.notifications.form.roomId.placeholder")}
|
||||
/>
|
||||
<FormTextField
|
||||
name="accessToken"
|
||||
fieldLabel={t("pages.notifications.form.accessToken.optionAccessToken")}
|
||||
placeholder={t("pages.notifications.form.accessToken.placeholder")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
{t("common.buttons.test")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={isSubmitting || isPatching}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleTest}
|
||||
loading={isTesting}
|
||||
>
|
||||
{t("common.buttons.test")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={isSubmitting || isPatching}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ const PageSpeedDetails = () => {
|
||||
const monitor = monitorData?.monitorData?.monitor;
|
||||
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
|
||||
const monitorStats = monitorData?.monitorStats || null;
|
||||
const recentChecks = monitor?.recentChecks ?? [];
|
||||
const latestCheck = recentChecks[recentChecks.length - 1];
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
@@ -75,8 +77,8 @@ const PageSpeedDetails = () => {
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(10)}
|
||||
>
|
||||
<PiePageSpeed latestCheck={monitor?.recentChecks?.[0]} />
|
||||
<PiePageSpeedLegend latestCheck={monitor?.recentChecks?.[0]} />
|
||||
<PiePageSpeed latestCheck={latestCheck} />
|
||||
<PiePageSpeedLegend latestCheck={latestCheck} />
|
||||
</Stack>
|
||||
</BasePage>
|
||||
);
|
||||
|
||||
@@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => {
|
||||
const checks: CheckSnapshot[] = [];
|
||||
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
||||
const isUp = Math.random() > 0.1;
|
||||
const responseTime = Math.floor(Math.random() * 80) + 20;
|
||||
const responseTime = Math.floor(Math.random() * 800) + 20;
|
||||
checks.push({
|
||||
id: `dummy-${i}`,
|
||||
status: isUp,
|
||||
statusCode: isUp ? 200 : 500,
|
||||
responseTime: responseTime,
|
||||
originalResponseTime: responseTime,
|
||||
message: "",
|
||||
createdAt: new Date(Date.now() - i * 60000).toISOString(),
|
||||
});
|
||||
|
||||
+584
-772
File diff suppressed because it is too large.
Load diff
@@ -1,56 +1,74 @@
|
||||
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import { Trash2, GripVertical } from "lucide-react";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import type { DropResult } from "@hello-pangea/dnd";
|
||||
import {
|
||||
ImageUpload,
|
||||
SwitchComponent,
|
||||
Button,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
ColorInput,
|
||||
} from "@/Components/inputs";
|
||||
import { ImageUpload, Button, Dialog } from "@/Components/inputs";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, FormProvider, useController } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
|
||||
import type { StatusPageFormData } from "@/Validation/statusPage";
|
||||
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
|
||||
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { SelectableMonitorTypes } from "@/Types/Monitor";
|
||||
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import timezones from "@/Utils/timezones.json";
|
||||
import { timezoneOptions } from "@/Utils/timezoneOptions";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { mutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
import { ThemePicker } from "./Components/ThemePicker";
|
||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
||||
import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField";
|
||||
import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField";
|
||||
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
|
||||
|
||||
const ThemePickerField = () => {
|
||||
const { field: themeField } = useController<StatusPageFormData, "theme">({
|
||||
name: "theme",
|
||||
});
|
||||
const { field: modeField } = useController<StatusPageFormData, "themeMode">({
|
||||
name: "themeMode",
|
||||
});
|
||||
return (
|
||||
<ThemePicker
|
||||
theme={themeField.value ?? "refined"}
|
||||
themeMode={modeField.value ?? "auto"}
|
||||
onThemeChange={themeField.onChange}
|
||||
onThemeModeChange={modeField.onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const LogoUploadField = () => {
|
||||
const { field } = useController<StatusPageFormData, "logo">({ name: "logo" });
|
||||
return (
|
||||
<ImageUpload
|
||||
src={field.value?.data}
|
||||
onChange={(file) => {
|
||||
field.onChange(file ? { data: file.src, contentType: file.file.type } : null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Derived from SelectableMonitorTypes so a newly supported monitor type is
|
||||
// offered here automatically; the previous hardcoded list silently omitted DNS.
|
||||
// "hardware" is appended because infrastructure monitors are created on their
|
||||
// own page and so are absent from the selectable set.
|
||||
const monitorsUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
|
||||
(type) => params.append("type", type)
|
||||
);
|
||||
[...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type));
|
||||
return `/monitors/team?${params.toString()}`;
|
||||
})();
|
||||
|
||||
interface TimezoneOption {
|
||||
_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const buildStatusPageKey = (slug: string | null | undefined) =>
|
||||
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
|
||||
|
||||
@@ -67,7 +85,7 @@ const CreateStatusPage = () => {
|
||||
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
|
||||
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||
const monitors = monitorsResponse ?? [];
|
||||
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
|
||||
|
||||
const { post, loading: isSubmittingPost } = usePost();
|
||||
const { put, loading: isSubmittingPut } = usePut();
|
||||
@@ -87,7 +105,7 @@ const CreateStatusPage = () => {
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { control, reset, handleSubmit, trigger } = form;
|
||||
const { reset, handleSubmit, trigger } = form;
|
||||
|
||||
// Reset form when defaults change (from fetched data)
|
||||
useEffect(() => {
|
||||
@@ -108,7 +126,7 @@ const CreateStatusPage = () => {
|
||||
};
|
||||
const handleBack = () => setCurrentStep((step) => step - 1);
|
||||
|
||||
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
|
||||
const watchedMonitorIds: string[] = form.watch("monitors");
|
||||
const computedTypes: MonitorDisplayType[] = useMemo(() => {
|
||||
const selectedMonitors = (watchedMonitorIds ?? [])
|
||||
.map((id) => monitors.find((m) => m.id === id))
|
||||
@@ -124,7 +142,7 @@ const CreateStatusPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("type", computedTypes);
|
||||
}, [computedTypes]);
|
||||
}, [computedTypes, form]);
|
||||
|
||||
const onError = (errors: any) => {
|
||||
logger.debug("Status page validation errors", errors);
|
||||
@@ -216,470 +234,227 @@ const CreateStatusPage = () => {
|
||||
return <BasePage>Loading...</BasePage>;
|
||||
}
|
||||
return (
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
|
||||
{isCreate && (
|
||||
<StepProgress
|
||||
steps={totalSteps}
|
||||
current={currentStep}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.access.title")}
|
||||
subtitle={t("pages.statusPages.form.access.description")}
|
||||
rightContent={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(SPACING.MD)}
|
||||
>
|
||||
<Controller
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit, onError)}
|
||||
>
|
||||
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
|
||||
{isCreate && (
|
||||
<StepProgress
|
||||
steps={totalSteps}
|
||||
current={currentStep}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.access.title")}
|
||||
subtitle={t("pages.statusPages.form.access.description")}
|
||||
rightContent={
|
||||
<FormSwitchField
|
||||
name="isPublished"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SwitchComponent
|
||||
checked={field.value ?? false}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
label={t("pages.statusPages.form.access.option.published.name")}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.statusPages.form.access.option.published.name")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.basicInfo.title")}
|
||||
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="companyName"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.name.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.url.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="customDomain"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
|
||||
)}
|
||||
helperText={
|
||||
fieldState.error?.message ||
|
||||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.monitors.title")}
|
||||
subtitle={t("pages.statusPages.form.monitors.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="monitors"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => {
|
||||
const selectedMonitors = field.value
|
||||
.map((id: string) => monitors.find((m) => m.id === id))
|
||||
.filter((m): m is Monitor => m !== undefined);
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const reordered = Array.from(field.value);
|
||||
const [removed] = reordered.splice(result.source.index, 1);
|
||||
reordered.splice(result.destination.index, 0, removed);
|
||||
field.onChange(reordered);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={monitors}
|
||||
getOptionLabel={(option: Monitor) => option.name}
|
||||
value={selectedMonitors}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||
}}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.monitors.option.monitors.label"
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={
|
||||
selectedMonitors.length === 0
|
||||
? t(
|
||||
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
||||
)
|
||||
: ""
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{selectedMonitors.length > 0 && (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="monitors-list">
|
||||
{(provided) => (
|
||||
<Stack
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{selectedMonitors.map((monitor, index) => (
|
||||
<Draggable
|
||||
key={monitor.id}
|
||||
draggableId={monitor.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<Stack
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
padding={theme.spacing(LAYOUT.XS)}
|
||||
marginTop={theme.spacing(SPACING.LG)}
|
||||
borderRadius={1}
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
cursor: "grab",
|
||||
"&:active": { cursor: "grabbing" },
|
||||
}}
|
||||
>
|
||||
<GripVertical size={20} />
|
||||
<Typography
|
||||
flexGrow={1}
|
||||
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
field.value.filter(
|
||||
(id: string) => id !== monitor.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
aria-label="Remove monitor"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.timezone.title")}
|
||||
subtitle={t("pages.statusPages.form.timezone.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="timezone"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={timezones}
|
||||
getOptionLabel={(option: TimezoneOption) => option.name}
|
||||
value={
|
||||
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
|
||||
}
|
||||
onChange={(_, newValue: TimezoneOption | null) => {
|
||||
field.onChange(newValue?._id ?? "");
|
||||
}}
|
||||
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.timezone.option.timezone.placeholder"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.basicInfo.title")}
|
||||
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormTextField
|
||||
name="companyName"
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.name.placeholder"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.appearance.title")}
|
||||
subtitle={t("pages.statusPages.form.appearance.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack alignItems={"center"}>
|
||||
<Controller
|
||||
name="logo"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ImageUpload
|
||||
src={field.value?.data}
|
||||
onChange={(file) => {
|
||||
if (file) {
|
||||
field.onChange({
|
||||
data: file.src,
|
||||
contentType: file.file.type,
|
||||
});
|
||||
} else {
|
||||
field.onChange(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name="url"
|
||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.url.placeholder"
|
||||
)}
|
||||
/>
|
||||
<FormTextField
|
||||
name="customDomain"
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
|
||||
)}
|
||||
helperText={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.helper"
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<ColorInput
|
||||
format="hex"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.monitors.title")}
|
||||
subtitle={t("pages.statusPages.form.monitors.description")}
|
||||
rightContent={
|
||||
<FormMultiSelectField
|
||||
sortable
|
||||
name="monitors"
|
||||
fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
||||
)}
|
||||
options={monitors}
|
||||
renderRow={(monitor) => (
|
||||
<Typography flexGrow={1}>
|
||||
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
|
||||
</Typography>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.theme.title")}
|
||||
subtitle={t("pages.statusPages.form.theme.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="theme"
|
||||
control={control}
|
||||
render={({ field: themeField }) => (
|
||||
<Controller
|
||||
name="themeMode"
|
||||
control={control}
|
||||
render={({ field: modeField }) => (
|
||||
<ThemePicker
|
||||
theme={themeField.value ?? "refined"}
|
||||
themeMode={modeField.value ?? "auto"}
|
||||
onThemeChange={themeField.onChange}
|
||||
onThemeModeChange={modeField.onChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(0) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.timezone.title")}
|
||||
subtitle={t("pages.statusPages.form.timezone.description")}
|
||||
rightContent={
|
||||
<FormAutocompleteField
|
||||
name="timezone"
|
||||
options={timezoneOptions}
|
||||
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.timezone.option.timezone.placeholder"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.appearance.title")}
|
||||
subtitle={t("pages.statusPages.form.appearance.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack alignItems={"center"}>
|
||||
<LogoUploadField />
|
||||
</Stack>
|
||||
<FormColorField
|
||||
name="color"
|
||||
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.theme.title")}
|
||||
subtitle={t("pages.statusPages.form.theme.description")}
|
||||
rightContent={<ThemePickerField />}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.customCSS.title")}
|
||||
subtitle={t("pages.statusPages.form.customCSS.description")}
|
||||
rightContent={
|
||||
<FormTextField
|
||||
name="customCSS"
|
||||
multiline
|
||||
rows={8}
|
||||
fieldLabel={t("pages.statusPages.form.customCSS.option.customCSS.label")}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormCheckboxField
|
||||
name="showCharts"
|
||||
label={t("pages.statusPages.form.features.option.showCharts.label")}
|
||||
/>
|
||||
<FormCheckboxField
|
||||
name="showInfrastructure"
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showInfrastructure.label"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.customCSS.title")}
|
||||
subtitle={t("pages.statusPages.form.customCSS.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="customCSS"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
multiline
|
||||
rows={8}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.customCSS.option.customCSS.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showStep(1) && (
|
||||
<ConfigBox
|
||||
title={t("pages.statusPages.form.features.title")}
|
||||
subtitle={t("pages.statusPages.form.features.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<Controller
|
||||
name="showCharts"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label={t("pages.statusPages.form.features.option.showCharts.label")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="showInfrastructure"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showInfrastructure.label"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* <Controller
|
||||
name="showUptimePercentage"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
{/*
|
||||
<FormCheckboxField
|
||||
name="showUptimePercentage"
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showUptimePercentage.label"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="showAdminLoginLink"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
}
|
||||
|
||||
<FormCheckboxField
|
||||
name="showAdminLoginLink"
|
||||
label={t(
|
||||
"pages.statusPages.form.features.option.showAdminLoginLink.label"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/> */}
|
||||
</Stack>
|
||||
}
|
||||
/> */}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={isCreate ? "space-between" : "flex-end"}
|
||||
>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={currentStep === 0}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("common.buttons.back")}
|
||||
</Button>
|
||||
)}
|
||||
{isCreate && currentStep < totalSteps - 1 ? (
|
||||
<Button
|
||||
key="wizard-next"
|
||||
type="button"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleNext}
|
||||
>
|
||||
{t("common.buttons.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="wizard-save"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={isCreate ? "space-between" : "flex-end"}
|
||||
>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={currentStep === 0}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("common.buttons.back")}
|
||||
</Button>
|
||||
)}
|
||||
{isCreate && currentStep < totalSteps - 1 ? (
|
||||
<Button
|
||||
key="wizard-next"
|
||||
type="button"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleNext}
|
||||
>
|
||||
{t("common.buttons.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="wizard-save"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</BasePage>
|
||||
</BasePage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BasePage, BaseFallback } from "@/Components/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
@@ -9,7 +9,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { resolveStatusPageTheme } from "@/Types/StatusPage";
|
||||
import {
|
||||
isStatusPageRange,
|
||||
resolveStatusPageTheme,
|
||||
type StatusPageRange,
|
||||
} from "@/Types/StatusPage";
|
||||
import {
|
||||
PUBLIC_STATUS_PAGE_PREFIX,
|
||||
type StatusPageResponse,
|
||||
@@ -80,21 +84,36 @@ const StatusPageView = () => {
|
||||
const { url } = useParams();
|
||||
const isAdmin = useIsAdmin();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const onRangeChange = (next: StatusPageRange) => {
|
||||
const updated = new URLSearchParams(searchParams);
|
||||
if (next === "latest") {
|
||||
updated.delete("range");
|
||||
} else {
|
||||
updated.set("range", next);
|
||||
}
|
||||
setSearchParams(updated, { replace: true });
|
||||
};
|
||||
|
||||
const onCustomDomainHost = isCustomDomainHost();
|
||||
const isPublic =
|
||||
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
||||
|
||||
const rawRange = searchParams.get("range");
|
||||
const range = isStatusPageRange(rawRange) ? rawRange : "latest";
|
||||
|
||||
const apiUrl = buildStatusPageApiPath({
|
||||
url,
|
||||
useCustomDomain: onCustomDomainHost,
|
||||
range,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useGet<StatusPageResponse>(
|
||||
apiUrl,
|
||||
{},
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
refreshInterval: range === "latest" ? 10000 : 60000,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -150,6 +169,10 @@ const StatusPageView = () => {
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
config={themeConfig}
|
||||
range={range}
|
||||
onRangeChange={onRangeChange}
|
||||
bucketTimezone={data.bucketTimezone ?? "Etc/UTC"}
|
||||
checkTTLDays={data.checkTTLDays}
|
||||
/>
|
||||
</StatusPageThemeProvider>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type BoldBarKind = "up" | "down" | "empty";
|
||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
||||
|
||||
export type BoldHeatCell = HeatCellKind;
|
||||
export type BoldBarKind = BarKind;
|
||||
export type BoldGaugeFill = "ok" | "warm" | "hot";
|
||||
|
||||
export interface BoldStyles {
|
||||
@@ -55,6 +57,7 @@ export const boldStyles = (
|
||||
fast: tokens.up,
|
||||
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
|
||||
slow: tokens.warn,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
@@ -62,12 +65,14 @@ export const boldStyles = (
|
||||
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
|
||||
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
|
||||
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
|
||||
degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`,
|
||||
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
|
||||
empty: "none",
|
||||
};
|
||||
|
||||
const barBg: Record<BoldBarKind, string> = {
|
||||
up: tokens.up,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type EditorialBarKind = "up" | "down" | "empty";
|
||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
||||
|
||||
export type EditorialHeatCell = HeatCellKind;
|
||||
export type EditorialBarKind = BarKind;
|
||||
export type EditorialGaugeFill = "ok" | "warm" | "hot";
|
||||
|
||||
export interface EditorialStyles {
|
||||
@@ -59,12 +61,14 @@ export const editorialStyles = (
|
||||
fast: tokens.up,
|
||||
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
|
||||
slow: tokens.warn,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const barBg: Record<EditorialBarKind, string> = {
|
||||
up: tokens.up,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type ModernBarKind = "up" | "down" | "empty";
|
||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
||||
|
||||
export type ModernHeatCell = HeatCellKind;
|
||||
export type ModernBarKind = BarKind;
|
||||
export type ModernGaugeFill = "ok" | "warm" | "hot";
|
||||
|
||||
export interface ModernStyles {
|
||||
@@ -86,12 +88,14 @@ export const modernStyles = (
|
||||
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
|
||||
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const barBg: Record<ModernBarKind, string> = {
|
||||
up: tokens.up,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
|
||||
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type RefinedBarKind = "up" | "down" | "empty";
|
||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
||||
|
||||
export type RefinedHeatCell = HeatCellKind;
|
||||
export type RefinedBarKind = BarKind;
|
||||
export type RefinedGaugeFill = "ok" | "warm" | "hot";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
@@ -71,12 +73,14 @@ export const refinedStyles = (
|
||||
fast: tokens.up,
|
||||
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
|
||||
slow: tokens.warn,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
const barBg: Record<RefinedBarKind, string> = {
|
||||
up: tokens.up,
|
||||
degraded: tokens.degraded,
|
||||
down: tokens.down,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import type { StatusPage, StatusPageRange } from "@/Types/StatusPage";
|
||||
import {
|
||||
getMonitorTypeLabel,
|
||||
STATUS_PAGE_RANGE_DAYS,
|
||||
STATUS_PAGE_RANGES,
|
||||
} from "@/Types/StatusPage";
|
||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||
import {
|
||||
ThemedHeatmap,
|
||||
type HeatCellKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||
import {
|
||||
ThemedHistogram,
|
||||
type BarKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||
import {
|
||||
ThemedInfrastructure,
|
||||
type GaugeFillLevel,
|
||||
@@ -26,10 +25,22 @@ import {
|
||||
resolveOverallStatus,
|
||||
statusBadgeKey,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
import {
|
||||
checksToCells,
|
||||
dailyBucketsToCells,
|
||||
type BarKind,
|
||||
type HeatCellKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { Icon } from "@/Components/design-elements";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
type StatusPageMonitor = Monitor;
|
||||
|
||||
export interface BaseStyles {
|
||||
page: SxProps<Theme>;
|
||||
@@ -82,9 +93,22 @@ interface Props {
|
||||
statusPage: StatusPage;
|
||||
monitors: StatusPageMonitor[];
|
||||
config: ThemeConfig<any>;
|
||||
range: StatusPageRange;
|
||||
onRangeChange: (range: StatusPageRange) => void;
|
||||
bucketTimezone: string;
|
||||
checkTTLDays?: number;
|
||||
}
|
||||
|
||||
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
export const BaseStatusPage = ({
|
||||
statusPage,
|
||||
monitors,
|
||||
config,
|
||||
range,
|
||||
onRangeChange,
|
||||
bucketTimezone,
|
||||
checkTTLDays,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { tokens, mode } = useStatusPageTheme();
|
||||
const styles = useMemo(
|
||||
@@ -126,33 +150,75 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
/>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Stack>
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
aria-label={t("pages.statusPages.rangeSelectorAria")}
|
||||
>
|
||||
{STATUS_PAGE_RANGES.map((value) => (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
key={value}
|
||||
aria-checked={range === value}
|
||||
onClick={() => onRangeChange(value)}
|
||||
sx={styles.chartSwitchButton(range === value)}
|
||||
>
|
||||
{t(`pages.statusPages.range.${value}`)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
{range !== "latest" &&
|
||||
checkTTLDays &&
|
||||
STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && (
|
||||
<Stack
|
||||
mb={LAYOUT.XS}
|
||||
alignSelf="flex-end"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
<Icon
|
||||
icon={TriangleAlert}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
<Typography>
|
||||
{t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
@@ -166,6 +232,16 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
|
||||
|
||||
const cells =
|
||||
range === "latest"
|
||||
? checksToCells(monitor.recentChecks ?? [])
|
||||
: dailyBucketsToCells(
|
||||
monitor.dailyChecks ?? [],
|
||||
STATUS_PAGE_RANGE_DAYS[range],
|
||||
bucketTimezone,
|
||||
t
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
@@ -226,13 +302,13 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
cells={cells}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
cells={cells}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
ThemedChartTooltip,
|
||||
ThemedDailyTooltip,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
|
||||
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import {
|
||||
computeBarHeights,
|
||||
computeDayBarHeights,
|
||||
MIN_HEIGHT_PCT,
|
||||
} from "@/Utils/DataUtils";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
import { enumerateDays, formatMs } from "@/Utils/TimeUtils";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export type BarKind = "up" | "degraded" | "down" | "empty";
|
||||
export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empty";
|
||||
|
||||
export interface ChartCell {
|
||||
key: string;
|
||||
barKind: BarKind;
|
||||
heatKind: HeatCellKind;
|
||||
heightPct: number; // histogram bar height
|
||||
responseTime: number; // 0 for empty cells
|
||||
tooltip: React.ReactNode | null;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const DAY_DOWN_THRESHOLD = 0.5;
|
||||
|
||||
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
|
||||
if (bucket.totalChecks === 0) return "empty";
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
if (upRatio >= 1) return "up";
|
||||
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
|
||||
return "degraded";
|
||||
};
|
||||
|
||||
export const classifyResponseTime = (
|
||||
status: boolean,
|
||||
responseTime: number | undefined
|
||||
): Exclude<HeatCellKind, "empty"> => {
|
||||
if (!status) return "down";
|
||||
const rt = responseTime ?? 0;
|
||||
if (rt > 500) return "slow";
|
||||
if (rt > 250) return "med";
|
||||
return "fast";
|
||||
};
|
||||
|
||||
const emptyCell = (key: string): ChartCell => ({
|
||||
key,
|
||||
barKind: "empty",
|
||||
heatKind: "empty",
|
||||
heightPct: MIN_HEIGHT_PCT,
|
||||
responseTime: 0,
|
||||
tooltip: null,
|
||||
ariaLabel: "",
|
||||
});
|
||||
|
||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||
...cells,
|
||||
...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) =>
|
||||
emptyCell(`empty-${cells.length + i}`)
|
||||
),
|
||||
];
|
||||
|
||||
export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
||||
const source = checks.slice(-MAX_RECENT_CHECKS);
|
||||
const heights = computeBarHeights(source);
|
||||
const cells = source.map(
|
||||
(check, i): ChartCell => ({
|
||||
key: check.id ?? String(i),
|
||||
barKind: check.status ? "up" : "down",
|
||||
heatKind: classifyResponseTime(check.status, check.responseTime),
|
||||
heightPct: heights[i] ?? MIN_HEIGHT_PCT,
|
||||
responseTime: check.responseTime ?? 0,
|
||||
tooltip: <ThemedChartTooltip check={check} />,
|
||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||
})
|
||||
);
|
||||
return padCells(cells, MAX_RECENT_CHECKS);
|
||||
};
|
||||
|
||||
export const dailyBucketsToCells = (
|
||||
buckets: DailyCheckBucket[],
|
||||
days: number,
|
||||
bucketTimezone: string,
|
||||
t: TFunction
|
||||
): ChartCell[] => {
|
||||
const byDate = new Map(buckets.map((b) => [b.date, b]));
|
||||
const heights = computeDayBarHeights(buckets);
|
||||
const enumerated = enumerateDays(days, bucketTimezone);
|
||||
return enumerated.map((date) => {
|
||||
const bucket = byDate.get(date);
|
||||
if (!bucket) return emptyCell(`day-${date}`);
|
||||
const barKind = classifyDay(bucket);
|
||||
return {
|
||||
key: `day-${date}`,
|
||||
barKind,
|
||||
heatKind:
|
||||
barKind === "up"
|
||||
? classifyResponseTime(true, bucket.avgResponseTime ?? 0)
|
||||
: barKind,
|
||||
heightPct: heights.get(date) ?? MIN_HEIGHT_PCT,
|
||||
responseTime: bucket.avgResponseTime ?? 0,
|
||||
tooltip: <ThemedDailyTooltip bucket={bucket} />,
|
||||
ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", {
|
||||
date,
|
||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => {
|
||||
const { t } = useTranslation();
|
||||
const { timezone } = useStatusPageTheme();
|
||||
return (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? formatMs(check.responseTime)
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{bucket.date}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chart.dayUptime", {
|
||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
||||
upChecks: bucket.upChecks.toLocaleString(),
|
||||
totalChecks: bucket.totalChecks.toLocaleString(),
|
||||
})}
|
||||
</Typography>
|
||||
{bucket.avgResponseTime !== null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chart.dayAvgResponse", {
|
||||
value: formatMs(bucket.avgResponseTime),
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,86 +1,49 @@
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
|
||||
const CELLS = MAX_RECENT_CHECKS;
|
||||
|
||||
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
|
||||
import type {
|
||||
ChartCell,
|
||||
HeatCellKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
|
||||
|
||||
interface Props {
|
||||
checks: CheckSnapshot[];
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
}
|
||||
|
||||
const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
|
||||
if (!check.status) return "down";
|
||||
const rt = check.responseTime ?? 0;
|
||||
if (rt > 500) return "slow";
|
||||
if (rt > 250) return "med";
|
||||
return "fast";
|
||||
};
|
||||
|
||||
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { timezone } = useStatusPageTheme();
|
||||
|
||||
const source = checks.slice(-CELLS);
|
||||
const padded: (CheckSnapshot | null)[] = [
|
||||
...source,
|
||||
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={containerSx}
|
||||
sx={[
|
||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
||||
]}
|
||||
role="img"
|
||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||
>
|
||||
{padded.map((check, i) => {
|
||||
if (!check) {
|
||||
{cells.map((cell) => {
|
||||
if (cell.heatKind === "empty") {
|
||||
return (
|
||||
<Box
|
||||
key={`empty-${i}`}
|
||||
key={cell.key}
|
||||
sx={cellSx("empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const kind = classify(check);
|
||||
const tooltipContent = (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? `${check.responseTime} ms`
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={check.id ?? i}
|
||||
title={tooltipContent}
|
||||
key={cell.key}
|
||||
title={cell.tooltip}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={cellSx(kind)}
|
||||
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
|
||||
sx={cellSx(cell.heatKind)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -2,101 +2,67 @@ import { useMemo } from "react";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
const CELLS = MAX_RECENT_CHECKS;
|
||||
const MIN_HEIGHT_PCT = 6;
|
||||
|
||||
export type BarKind = "up" | "down" | "empty";
|
||||
import { formatMs } from "@/Utils/TimeUtils";
|
||||
import type {
|
||||
BarKind,
|
||||
ChartCell,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
|
||||
|
||||
interface Props {
|
||||
checks: CheckSnapshot[];
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
statsSx: SxProps<Theme>;
|
||||
statsGap?: number;
|
||||
}
|
||||
|
||||
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
|
||||
check.status ? "up" : "down";
|
||||
|
||||
export const ThemedHistogram = ({
|
||||
checks,
|
||||
cells,
|
||||
containerSx,
|
||||
barSx,
|
||||
statsSx,
|
||||
statsGap = 1,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { timezone } = useStatusPageTheme();
|
||||
|
||||
const { padded, max, avg, peak } = useMemo(() => {
|
||||
const source = checks.slice(-CELLS);
|
||||
const out: (CheckSnapshot | null)[] = [
|
||||
...source,
|
||||
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||
];
|
||||
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
|
||||
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
|
||||
const { avg, peak } = useMemo(() => {
|
||||
const valid = cells.filter((cell) => cell.responseTime > 0);
|
||||
const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0;
|
||||
const avgRt = valid.length
|
||||
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
|
||||
? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length
|
||||
: 0;
|
||||
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
|
||||
}, [checks]);
|
||||
return { avg: formatMs(avgRt), peak: formatMs(maxRt) };
|
||||
}, [cells]);
|
||||
|
||||
return (
|
||||
<Stack gap={statsGap}>
|
||||
<Box sx={containerSx}>
|
||||
{padded.map((check, i) => {
|
||||
if (!check) {
|
||||
<Box
|
||||
sx={[
|
||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
||||
]}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
if (cell.barKind === "empty") {
|
||||
return (
|
||||
<Box
|
||||
key={`empty-${i}`}
|
||||
sx={barSx("empty", MIN_HEIGHT_PCT)}
|
||||
key={cell.key}
|
||||
sx={barSx("empty", cell.heightPct)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const height = Math.max(
|
||||
MIN_HEIGHT_PCT,
|
||||
Math.round((check.responseTime / max) * 100)
|
||||
);
|
||||
const tooltipContent = (
|
||||
<Stack gap={0.25}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
>
|
||||
{check.status
|
||||
? `${check.responseTime} ms`
|
||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatDateWithTz(
|
||||
check.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
timezone
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={check.id ?? i}
|
||||
title={tooltipContent}
|
||||
key={cell.key}
|
||||
title={cell.tooltip}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={barSx(tone(check), height)}
|
||||
aria-label={`${check.responseTime} ms`}
|
||||
sx={barSx(cell.barKind, cell.heightPct)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface InfraSx {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
monitor: Monitor & { checks?: Monitor["recentChecks"] };
|
||||
monitor: Monitor;
|
||||
sxApi: InfraSx;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ const heatLevel = (value: number): GaugeFillLevel =>
|
||||
|
||||
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
|
||||
const recentChecks = monitor.recentChecks ?? [];
|
||||
const latest = recentChecks[recentChecks.length - 1];
|
||||
|
||||
const renderEmpty = () => (
|
||||
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import { TextField, Button, ColorInput } from "@/Components/inputs";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { mutate } from "swr";
|
||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
@@ -14,6 +14,8 @@ import type { TagFormData } from "@/Validation/tag";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTagsForm } from "@/Hooks/useTagsForm";
|
||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
||||
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
|
||||
|
||||
const TagsCreatePage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,7 +36,7 @@ const TagsCreatePage = () => {
|
||||
defaultValues: defaults,
|
||||
});
|
||||
|
||||
const { control, reset, handleSubmit } = form;
|
||||
const { reset, handleSubmit } = form;
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaults);
|
||||
@@ -51,67 +53,49 @@ const TagsCreatePage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.tags.form.name.title")}
|
||||
subtitle={t("pages.tags.form.name.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
defaultValue={defaults.name}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.tags.form.name.optionName")}
|
||||
placeholder={t("pages.tags.form.name.placeholder")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.tags.form.color.title")}
|
||||
subtitle={t("pages.tags.form.color.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
defaultValue={defaults.color}
|
||||
render={({ field }) => (
|
||||
<ColorInput
|
||||
format="hex"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
fieldLabel={t("pages.tags.form.color.optionName")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Button
|
||||
loading={isSubmitting || isPatching}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
<ConfigBox
|
||||
title={t("pages.tags.form.name.title")}
|
||||
subtitle={t("pages.tags.form.name.description")}
|
||||
rightContent={
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.tags.form.name.optionName")}
|
||||
placeholder={t("pages.tags.form.name.placeholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.tags.form.color.title")}
|
||||
subtitle={t("pages.tags.form.color.description")}
|
||||
rightContent={
|
||||
<FormColorField
|
||||
name="color"
|
||||
fieldLabel={t("pages.tags.form.color.optionName")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
<Button
|
||||
loading={isSubmitting || isPatching}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@ import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
|
||||
export const GeoChecksTable = ({
|
||||
geoChecks,
|
||||
@@ -69,7 +68,7 @@ export const GeoChecksTable = ({
|
||||
content: t("common.table.headers.responseTime"),
|
||||
render: (row) => {
|
||||
if (!row.timings?.total) return "N/A";
|
||||
return prettyMilliseconds(row.timings.total, { compact: true });
|
||||
return formatMs(row.timings.total);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -38,6 +38,11 @@ interface CertificateResponse {
|
||||
certificateDate: string;
|
||||
}
|
||||
|
||||
interface DomainResponse {
|
||||
domain: string;
|
||||
expiryDate: string;
|
||||
}
|
||||
|
||||
const UptimeDetailsPage = () => {
|
||||
const theme = useTheme();
|
||||
const isAdmin = useIsAdmin();
|
||||
@@ -57,7 +62,6 @@ const UptimeDetailsPage = () => {
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.append("dateRange", dateRange);
|
||||
params.append("normalize", "true");
|
||||
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
|
||||
}, [monitorId, dateRange]);
|
||||
|
||||
@@ -72,6 +76,7 @@ const UptimeDetailsPage = () => {
|
||||
);
|
||||
|
||||
const monitorData = monitorDetailsData?.monitorData;
|
||||
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
|
||||
@@ -102,6 +107,29 @@ const UptimeDetailsPage = () => {
|
||||
);
|
||||
}, [certificateData, uiTimezone]);
|
||||
|
||||
// Domain expiry fetch - only for HTTP monitors
|
||||
const domainUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http") {
|
||||
return null;
|
||||
}
|
||||
return `/monitors/domain/${monitorId}`;
|
||||
}, [monitorId, monitor?.type]);
|
||||
|
||||
const { data: domainData } = useGet<DomainResponse>(
|
||||
domainUrl,
|
||||
{},
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const domainExpiry = useMemo(() => {
|
||||
if (!domainData?.expiryDate) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
formatDateWithTz(domainData.expiryDate, certificateDateFormat, uiTimezone) ?? "N/A"
|
||||
);
|
||||
}, [domainData, uiTimezone]);
|
||||
|
||||
const checksUrl = useMemo(() => {
|
||||
if (!monitorId || !monitor?.type) {
|
||||
return null;
|
||||
@@ -165,10 +193,18 @@ const UptimeDetailsPage = () => {
|
||||
{ keepPreviousData: true, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!monitorData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
|
||||
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
|
||||
|
||||
const geoLocations = monitor?.geoCheckLocations;
|
||||
const geoLocations = monitor.geoCheckLocations;
|
||||
|
||||
const checks = checksData?.checks ?? [];
|
||||
const checksCount = checksData?.checksCount ?? 0;
|
||||
@@ -185,6 +221,7 @@ const UptimeDetailsPage = () => {
|
||||
monitor={monitor}
|
||||
monitorStats={monitorStats}
|
||||
certificateExpiry={certificateExpiry}
|
||||
domainExpiry={domainExpiry}
|
||||
/>
|
||||
<HeaderTimeRange
|
||||
isLoading={monitorIsLoading || checksIsLoading}
|
||||
@@ -215,7 +252,7 @@ const UptimeDetailsPage = () => {
|
||||
/>
|
||||
</Stack>
|
||||
<HistogramDetails
|
||||
checks={monitorData?.groupedChecks || []}
|
||||
checks={monitorData.groupedChecks}
|
||||
range={dateRange}
|
||||
/>
|
||||
<ChecksTable
|
||||
|
||||
+54
-10
@@ -149,6 +149,15 @@ export interface GroupedCheck {
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface GroupedUptimeCheck extends GroupedCheck {
|
||||
avgDns: number;
|
||||
avgTcp: number;
|
||||
avgTls: number;
|
||||
avgRequest: number;
|
||||
avgFirstByte: number;
|
||||
avgDownload: number;
|
||||
}
|
||||
|
||||
export interface PageSpeedGroupedCheck {
|
||||
bucketDate: string;
|
||||
performance: number;
|
||||
@@ -241,18 +250,53 @@ export interface ChecksSummary {
|
||||
downChecks: number;
|
||||
}
|
||||
|
||||
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
|
||||
originalResponseTime: number;
|
||||
};
|
||||
export type SnapshotCpuInfo = Pick<
|
||||
CheckCpuInfo,
|
||||
| "physical_core"
|
||||
| "logical_core"
|
||||
| "frequency"
|
||||
| "current_frequency"
|
||||
| "temperature"
|
||||
| "usage_percent"
|
||||
>;
|
||||
export type SnapshotMemoryInfo = Pick<
|
||||
CheckMemoryInfo,
|
||||
"total_bytes" | "used_bytes" | "usage_percent"
|
||||
>;
|
||||
export type SnapshotDiskInfo = Pick<
|
||||
CheckDiskInfo,
|
||||
"device" | "total_bytes" | "used_bytes" | "usage_percent"
|
||||
>;
|
||||
export type SnapshotHostInfo = Pick<CheckHostInfo, "os" | "platform" | "pretty_name">;
|
||||
|
||||
export type CheckSnapshot = Pick<
|
||||
Check,
|
||||
| "id"
|
||||
| "status"
|
||||
| "responseTime"
|
||||
| "statusCode"
|
||||
| "message"
|
||||
| "createdAt"
|
||||
| "accessibility"
|
||||
| "bestPractices"
|
||||
| "seo"
|
||||
| "performance"
|
||||
| "audits"
|
||||
> & {
|
||||
cpu?: SnapshotCpuInfo;
|
||||
memory?: SnapshotMemoryInfo;
|
||||
disk?: SnapshotDiskInfo[];
|
||||
host?: SnapshotHostInfo;
|
||||
};
|
||||
export interface HasResponseTime {
|
||||
responseTime: number;
|
||||
}
|
||||
|
||||
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
|
||||
originalResponseTime: number;
|
||||
};
|
||||
|
||||
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
|
||||
originalAvgResponseTime: number;
|
||||
};
|
||||
export interface DailyCheckBucket {
|
||||
monitorId: string;
|
||||
date: string;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
downChecks: number;
|
||||
avgResponseTime: number | null; // null when no check that day recorded a response time
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
|
||||
import type {
|
||||
GroupedCheck,
|
||||
CheckSnapshot,
|
||||
GroupedUptimeCheck,
|
||||
DailyCheckBucket,
|
||||
} from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||
@@ -20,6 +25,56 @@ export const MonitorTypes = [
|
||||
] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
// The subset offered by the CreateMonitor type selector, in display order.
|
||||
// pagespeed/hardware are set by their own pages; unknown is never selectable.
|
||||
export const SelectableMonitorTypes = [
|
||||
"http",
|
||||
"ping",
|
||||
"docker",
|
||||
"port",
|
||||
"game",
|
||||
"grpc",
|
||||
"websocket",
|
||||
"dns",
|
||||
] as const;
|
||||
export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number];
|
||||
|
||||
// Irregular i18n key fragments shared by the type selector's label
|
||||
// (pages.common.monitors.monitorTypes.<fragment>), its description
|
||||
// (pages.createMonitor.form.type.<fragment>Description), and status page
|
||||
// type labels (via getMonitorTypeLabel). Covers every type with a label;
|
||||
// only selectable types have description keys.
|
||||
export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
|
||||
Partial<Record<MonitorType, string>> = {
|
||||
http: "optionHttp",
|
||||
ping: "optionPing",
|
||||
docker: "optionDocker",
|
||||
port: "optionPort",
|
||||
game: "optionGame",
|
||||
grpc: "optionGrpc",
|
||||
websocket: "optionWebSocket",
|
||||
dns: "optionDns",
|
||||
hardware: "optionHardware",
|
||||
pagespeed: "optionPagespeed",
|
||||
};
|
||||
|
||||
export const MonitorIntervalOptions = [
|
||||
{ value: 15000, labelKey: "fifteenSeconds" },
|
||||
{ value: 30000, labelKey: "thirtySeconds" },
|
||||
{ value: 60000, labelKey: "oneMinute" },
|
||||
{ value: 120000, labelKey: "twoMinutes" },
|
||||
{ value: 180000, labelKey: "threeMinutes" },
|
||||
{ value: 240000, labelKey: "fourMinutes" },
|
||||
{ value: 300000, labelKey: "fiveMinutes" },
|
||||
{ value: 600000, labelKey: "tenMinutes" },
|
||||
{ value: 900000, labelKey: "fifteenMinutes" },
|
||||
{ value: 1800000, labelKey: "thirtyMinutes" },
|
||||
] as const;
|
||||
|
||||
export const GeoCheckIntervalOptions = MonitorIntervalOptions.filter(
|
||||
({ value }) => value >= 300000
|
||||
);
|
||||
|
||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||
|
||||
@@ -42,7 +97,9 @@ export const MonitorStatuses = [
|
||||
] as const;
|
||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
||||
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
|
||||
export const DefaultMonitorMatchMethod: MonitorMatchMethod = "equal";
|
||||
|
||||
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||
@@ -97,6 +154,7 @@ export interface Monitor {
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
dailyChecks?: DailyCheckBucket[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -137,7 +195,7 @@ export interface MonitorStats {
|
||||
|
||||
export interface MonitorData {
|
||||
monitor: Monitor;
|
||||
groupedChecks: GroupedCheck[];
|
||||
groupedChecks: GroupedUptimeCheck[];
|
||||
groupedUpChecks: GroupedCheck[];
|
||||
groupedDownChecks: GroupedCheck[];
|
||||
groupedAvgResponseTime: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ export const NotificationChannels = [
|
||||
"slack",
|
||||
"discord",
|
||||
"webhook",
|
||||
"rocket_chat",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import { monitorTypeLabelKey } from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
export type MonitorDisplayType = "uptime" | "infrastructure";
|
||||
|
||||
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
|
||||
http: "pages.common.monitors.monitorTypes.optionHttp",
|
||||
ping: "pages.common.monitors.monitorTypes.optionPing",
|
||||
docker: "pages.common.monitors.monitorTypes.optionDocker",
|
||||
port: "pages.common.monitors.monitorTypes.optionPort",
|
||||
game: "pages.common.monitors.monitorTypes.optionGame",
|
||||
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
|
||||
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
|
||||
hardware: "pages.common.monitors.monitorTypes.optionHardware",
|
||||
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
|
||||
};
|
||||
|
||||
export const getMonitorTypeLabel = (
|
||||
type: MonitorType,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
const key = MONITOR_TYPE_KEYS[type];
|
||||
return key ? t(key) : type;
|
||||
const fragment = monitorTypeLabelKey[type];
|
||||
return fragment ? t(`pages.common.monitors.monitorTypes.${fragment}`) : type;
|
||||
};
|
||||
|
||||
export const STATUS_PAGE_THEMES = [
|
||||
@@ -35,6 +24,20 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
|
||||
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
|
||||
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
|
||||
|
||||
export const STATUS_PAGE_DAY_RANGES = ["30d", "60d", "90d"] as const;
|
||||
export type StatusPageDayRange = (typeof STATUS_PAGE_DAY_RANGES)[number];
|
||||
export const STATUS_PAGE_RANGES = ["latest", ...STATUS_PAGE_DAY_RANGES] as const;
|
||||
export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number];
|
||||
|
||||
export const STATUS_PAGE_RANGE_DAYS: Record<StatusPageDayRange, number> = {
|
||||
"30d": 30,
|
||||
"60d": 60,
|
||||
"90d": 90,
|
||||
};
|
||||
|
||||
export const isStatusPageRange = (value: string | null): value is StatusPageRange =>
|
||||
STATUS_PAGE_RANGES.some((range) => range === value);
|
||||
|
||||
export const resolveStatusPageTheme = (
|
||||
value: string | null | undefined
|
||||
): StatusPageTheme =>
|
||||
@@ -78,4 +81,7 @@ export interface StatusPage {
|
||||
export interface StatusPageResponse {
|
||||
statusPage: StatusPage;
|
||||
monitors: Monitor[];
|
||||
range?: StatusPageDayRange; // present only when range !== "latest"
|
||||
bucketTimezone?: string;
|
||||
checkTTLDays?: number;
|
||||
}
|
||||
Vendored
+2
-1
@@ -3,7 +3,8 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_API_V2_BASE_URL?: string;
|
||||
readonly VITE_APP_API_BASE_URL?: string;
|
||||
readonly VITE_APP_CLIENT_HOST?: string;
|
||||
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosError } from "axios";
|
||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { runtimeConfig } from "@/Utils/runtimeConfig";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
|
||||
const BASE_URL =
|
||||
runtimeConfig.apiBaseUrl || import.meta.env.VITE_APP_API_BASE_URL || "/api/v1";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
|
||||
@@ -1,47 +1,3 @@
|
||||
const MIN_OUT = 10;
|
||||
const MAX_OUT = 100;
|
||||
|
||||
export const normalizeResponseTimes = <T, K extends keyof T>(
|
||||
checks: T[],
|
||||
key: K
|
||||
): (T & { normalResponseTime: number })[] => {
|
||||
if (!Array.isArray(checks) || checks.length === 0)
|
||||
return checks as (T & {
|
||||
normalResponseTime: number;
|
||||
})[];
|
||||
|
||||
if (checks.length === 1) {
|
||||
return [
|
||||
{
|
||||
...checks[0],
|
||||
normalResponseTime: 50,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const getVal = (check: T): number => {
|
||||
const v = check[key] as unknown;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
||||
};
|
||||
|
||||
const { min, max } = checks.reduce(
|
||||
(acc, check) => {
|
||||
const v = getVal(check);
|
||||
if (v > acc.max) acc.max = v;
|
||||
if (v < acc.min) acc.min = v;
|
||||
return acc;
|
||||
},
|
||||
{ max: -Infinity, min: Infinity }
|
||||
);
|
||||
|
||||
const range = max - min || 1;
|
||||
|
||||
return checks.map((check) => ({
|
||||
...check,
|
||||
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
|
||||
}));
|
||||
};
|
||||
|
||||
// Interpolate color between three theme colors over 0-100 range.
|
||||
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
|
||||
export const getResponseColor = (
|
||||
@@ -129,3 +85,53 @@ export const getResponseColor = (
|
||||
}
|
||||
return normalizeToHex(safe.end);
|
||||
};
|
||||
|
||||
export const MIN_HEIGHT_PCT = 8;
|
||||
const CAP_PERCENTILE = 95;
|
||||
|
||||
const percentile = (sortedValues: number[], p: number): number => {
|
||||
const index = (p / 100) * (sortedValues.length - 1);
|
||||
const lower = Math.floor(index);
|
||||
const upper = lower + 1;
|
||||
const weight = index % 1;
|
||||
if (upper >= sortedValues.length) return sortedValues[lower];
|
||||
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
|
||||
};
|
||||
|
||||
export const computeBarHeights = (
|
||||
checks: { responseTime?: number | null }[]
|
||||
): number[] => {
|
||||
const values = checks.map((check) => {
|
||||
return typeof check.responseTime === "number" && Number.isFinite(check.responseTime)
|
||||
? Math.max(0, check.responseTime)
|
||||
: 0;
|
||||
});
|
||||
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
const cap = percentile(sorted, CAP_PERCENTILE);
|
||||
if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT);
|
||||
return values.map((value) => {
|
||||
return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100));
|
||||
});
|
||||
};
|
||||
|
||||
export const computeDayBarHeights = (
|
||||
buckets: { date: string; avgResponseTime: number | null }[]
|
||||
): Map<string, number> => {
|
||||
const values = buckets.map((bucket) =>
|
||||
typeof bucket.avgResponseTime === "number" && Number.isFinite(bucket.avgResponseTime)
|
||||
? Math.max(0, bucket.avgResponseTime)
|
||||
: 0
|
||||
);
|
||||
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
const cap = percentile(sorted, CAP_PERCENTILE);
|
||||
return new Map(
|
||||
buckets.map((bucket, i) => [
|
||||
bucket.date,
|
||||
cap <= 0
|
||||
? MIN_HEIGHT_PCT
|
||||
: Math.max(MIN_HEIGHT_PCT, Math.min(100, (values[i] / cap) * 100)),
|
||||
])
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,31 @@
|
||||
/**
|
||||
* Intl.NumberFormat instance for percentage formatting.
|
||||
* Reused across all formatting calls for performance.
|
||||
*
|
||||
* Two decimals rather than one: at a single decimal every value above 99.95%
|
||||
* renders as "100.0%", so a period containing real downtime is reported as
|
||||
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
|
||||
* different SLA commitments — into the same figure.
|
||||
*/
|
||||
const percentageFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Formats a decimal value as a percentage string.
|
||||
* @param value - Decimal value (e.g., 0.75 for 75%)
|
||||
* @returns Formatted percentage string (e.g., "75.0%")
|
||||
* @returns Formatted percentage string (e.g., "75.00%")
|
||||
* @example
|
||||
* formatPercentage(0.75) // "75.0%"
|
||||
* formatPercentage(1) // "100.0%"
|
||||
* formatPercentage(0.5432) // "54.3%"
|
||||
* formatPercentage(0.75) // "75.00%"
|
||||
* formatPercentage(1) // "100.00%"
|
||||
* formatPercentage(0.5432) // "54.32%"
|
||||
* formatPercentage(0.9999) // "99.99%"
|
||||
*/
|
||||
export const formatPercentage = (value: number): string => {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return "0.0%";
|
||||
return "0.00%";
|
||||
}
|
||||
return percentageFormatter.format(value);
|
||||
};
|
||||
@@ -27,11 +33,11 @@ export const formatPercentage = (value: number): string => {
|
||||
/**
|
||||
* Formats a whole number percentage value as a percentage string.
|
||||
* @param value - Whole number percentage (e.g., 75 for 75%)
|
||||
* @returns Formatted percentage string (e.g., "75.0%")
|
||||
* @returns Formatted percentage string (e.g., "75.00%")
|
||||
* @example
|
||||
* formatPercentageFromWhole(75) // "75.0%"
|
||||
* formatPercentageFromWhole(100) // "100.0%"
|
||||
* formatPercentageFromWhole(54.32) // "54.3%"
|
||||
* formatPercentageFromWhole(75) // "75.00%"
|
||||
* formatPercentageFromWhole(100) // "100.00%"
|
||||
* formatPercentageFromWhole(54.32) // "54.32%"
|
||||
*/
|
||||
export const formatPercentageFromWhole = (value: number): string => {
|
||||
return formatPercentage(value / 100);
|
||||
|
||||
@@ -20,8 +20,19 @@ export const colors = {
|
||||
brandGreen: "#13715B",
|
||||
brandGreenLight: "#4DAF94",
|
||||
green200: "#ECF7F2",
|
||||
green700: "#008300",
|
||||
yellow200: "#FFF4E5",
|
||||
yellow500: "#EDA100",
|
||||
yellow600: "#C98500",
|
||||
orange500: "#FFA500",
|
||||
orange600: "#EB6834",
|
||||
orange700: "#D95926",
|
||||
blue400: "#3987E5",
|
||||
blue500: "#2A78D6",
|
||||
aqua500: "#1BAF7A",
|
||||
aqua600: "#199E70",
|
||||
magenta300: "#E87BA4",
|
||||
magenta400: "#D55181",
|
||||
};
|
||||
|
||||
export const lightPalette = {
|
||||
@@ -38,6 +49,16 @@ export const lightPalette = {
|
||||
running: colors.green200,
|
||||
paused: colors.yellow200,
|
||||
},
|
||||
chart: {
|
||||
phases: {
|
||||
dns: colors.magenta300,
|
||||
tcp: colors.orange600,
|
||||
tls: colors.aqua500,
|
||||
request: colors.yellow500,
|
||||
firstByte: colors.blue500,
|
||||
download: colors.green700,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const darkPalette = {
|
||||
@@ -58,4 +79,14 @@ export const darkPalette = {
|
||||
running: alpha(colors.brandGreen, 0.18),
|
||||
paused: alpha(colors.orange500, 0.18),
|
||||
},
|
||||
chart: {
|
||||
phases: {
|
||||
dns: colors.magenta400,
|
||||
tcp: colors.orange700,
|
||||
tls: colors.aqua600,
|
||||
request: colors.yellow600,
|
||||
firstByte: colors.blue500,
|
||||
download: colors.green700,
|
||||
},
|
||||
},
|
||||
};
|
||||
Loaded 100 of 429 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user