Compare commits
278
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a236bec6c1 | ||
|
|
b4d127c6ce | ||
|
|
0dd977eeef | ||
|
|
58a77d6aa2 | ||
|
|
c42932a7b3 | ||
|
|
c1bb1fd29d | ||
|
|
03ce8f84ea | ||
|
|
77f943a7c7 | ||
|
|
9248870716 | ||
|
|
a3e24b5420 | ||
|
|
3611aee468 | ||
|
|
61bf2a0dfb | ||
|
|
052b9377bc | ||
|
|
ba7cc3bb0c | ||
|
|
51081a6a0e | ||
|
|
605b4713af | ||
|
|
81d6c4651f | ||
|
|
74f22a3e70 | ||
|
|
ce568424a3 | ||
|
|
9583332852 | ||
|
|
afb30dceff | ||
|
|
7aae57e9ab | ||
|
|
36cdb20dfc | ||
|
|
e62e4898b9 | ||
|
|
3182141d0a | ||
|
|
cdc58e5905 | ||
|
|
7ef1803cf0 | ||
|
|
6e341108bb | ||
|
|
af6c242a18 | ||
|
|
e2b012fdde | ||
|
|
e5ef14215f | ||
|
|
602597abe2 | ||
|
|
3bdfe5567d | ||
|
|
56aa7b3e1a | ||
|
|
82cb59ae9e | ||
|
|
8e6a8e5a06 | ||
|
|
7f15811fdf | ||
|
|
86cfc89545 | ||
|
|
5c9d6b5523 | ||
|
|
8b079905a3 | ||
|
|
7a01a8a8a3 | ||
|
|
f56f91c31d | ||
|
|
dea0ff106f | ||
|
|
445b594cab | ||
|
|
4f4863db75 | ||
|
|
a4d818e0c5 | ||
|
|
378a40ce84 | ||
|
|
a250bf38aa | ||
|
|
18408a6bf6 | ||
|
|
6dcfb67246 | ||
|
|
8fb13273bf | ||
|
|
a2454cbab6 | ||
|
|
15ceb4af46 | ||
|
|
24cbcec2c9 | ||
|
|
e55ae64222 | ||
|
|
4dfeb71a9d | ||
|
|
df707ab194 | ||
|
|
16aec18c28 | ||
|
|
de39688663 | ||
|
|
4ed89ace23 | ||
|
|
7961a9a88e | ||
|
|
805ee93914 | ||
|
|
a967c12ba5 | ||
|
|
3f8198f3df | ||
|
|
658c7c090f | ||
|
|
4667c59573 | ||
|
|
6978a7e2bb | ||
|
|
7f33251961 | ||
|
|
46740aa4b9 | ||
|
|
92fd405395 | ||
|
|
9f32eb6362 | ||
|
|
b200157f28 | ||
|
|
6323f70bd2 | ||
|
|
7681edbf49 | ||
|
|
66c406151a | ||
|
|
d139e0b643 | ||
|
|
1a8a18248b | ||
|
|
26ed713923 | ||
|
|
9fa2980204 | ||
|
|
0535ddbc38 | ||
|
|
caca71dce5 | ||
|
|
08d53b80b7 | ||
|
|
d8007a9a5f | ||
|
|
df95ee7687 | ||
|
|
e07c82611c | ||
|
|
e76fc05f8d | ||
|
|
4d7c5ca885 | ||
|
|
61d40c7cd8 | ||
|
|
88dc2f4096 | ||
|
|
6f41167456 | ||
|
|
40977775f0 | ||
|
|
93dd49c875 | ||
|
|
a2db7b46d6 | ||
|
|
58f5896d15 | ||
|
|
aa346abf5b | ||
|
|
1ee8a873f6 | ||
|
|
fdd935c676 | ||
|
|
d12a6cce8a | ||
|
|
35a6d5711d | ||
|
|
8c9a4bd6ec | ||
|
|
3f33b302f9 | ||
|
|
96567a9818 | ||
|
|
5489a107f9 | ||
|
|
9f695ecdcc | ||
|
|
c8dc5b93cb | ||
|
|
95e415ed13 | ||
|
|
59e85f7613 | ||
|
|
e1db8ea513 | ||
|
|
f9f94cc33c | ||
|
|
a17309e17c | ||
|
|
57d714ac59 | ||
|
|
60e50cb5db | ||
|
|
aa282439fc | ||
|
|
f92d08810a | ||
|
|
9222d0b62d | ||
|
|
4b1e8fb002 | ||
|
|
837c4598ff | ||
|
|
0b626f991f | ||
|
|
03b6d5976c | ||
|
|
968eb6a2a8 | ||
|
|
465e673f4b | ||
|
|
422d13ef25 | ||
|
|
666d522ee9 | ||
|
|
660346d2bc | ||
|
|
07b08d0037 | ||
|
|
71575a9d86 | ||
|
|
e4183baf75 | ||
|
|
4e3630465c | ||
|
|
2e523cc431 | ||
|
|
1a2d11e6de | ||
|
|
4b92b2a8df | ||
|
|
fc2c3aa6a6 | ||
|
|
2eb6850cb9 | ||
|
|
6f99249d62 | ||
|
|
9aa87f57ae | ||
|
|
16c7a007d5 | ||
|
|
92f7a929fa | ||
|
|
5e9c85f372 | ||
|
|
901b5a43c8 | ||
|
|
c9dea5c0ae | ||
|
|
22f7d002fd | ||
|
|
63dd455e4a | ||
|
|
64d1ba08f9 | ||
|
|
b94e6052c3 | ||
|
|
d6cddbd812 | ||
|
|
fe03451393 | ||
|
|
b6a3d1e34d | ||
|
|
9bfe146e2b | ||
|
|
c7945aa4a9 | ||
|
|
0d41e7229b | ||
|
|
8d3d764a0d | ||
|
|
35679fb433 | ||
|
|
4eb2f0bee3 | ||
|
|
2cb803948c | ||
|
|
85589d6d3a | ||
|
|
93317cbba7 | ||
|
|
8ae7638432 | ||
|
|
4f01ed7019 | ||
|
|
b4e4ec1110 | ||
|
|
bda3a7e91c | ||
|
|
03d681f212 | ||
|
|
69ea8eaf6b | ||
|
|
bd14ea3461 | ||
|
|
3616b69925 | ||
|
|
e12e198aed | ||
|
|
36208aacea | ||
|
|
f78ea7ab9c | ||
|
|
a8e55db134 | ||
|
|
50b734cf24 | ||
|
|
a3f7ac8ae3 | ||
|
|
4525f8035b | ||
|
|
8274eb2ec9 | ||
|
|
b6c699c141 | ||
|
|
dd463bb60b | ||
|
|
32fbb8f434 | ||
|
|
28ebdcb216 | ||
|
|
c762f9425a | ||
|
|
b3206f4011 | ||
|
|
c0a8398baa | ||
|
|
2b3c601a05 | ||
|
|
b4135e26ce | ||
|
|
0453b790af | ||
|
|
7631e65cd2 | ||
|
|
2ab983eff5 | ||
|
|
75ef6c934d | ||
|
|
ba6c9a481d | ||
|
|
5c889f3a63 | ||
|
|
c088956d6f | ||
|
|
30d7b3d258 | ||
|
|
53355cad04 | ||
|
|
07efd08837 | ||
|
|
a9a598941f | ||
|
|
ad2954e326 | ||
|
|
d77de3b6bc | ||
|
|
e0e260be8f | ||
|
|
634885c362 | ||
|
|
1b162578c2 | ||
|
|
01c0e9ef4d | ||
|
|
7b207f2ccb | ||
|
|
6bca0ed00b | ||
|
|
d76da4b00c | ||
|
|
6eabaebd86 | ||
|
|
f476a57748 | ||
|
|
e600f293b0 | ||
|
|
c43241eb52 | ||
|
|
86ce30b5f4 | ||
|
|
08887bbc58 | ||
|
|
ce97e201c9 | ||
|
|
229027830d | ||
|
|
5876b97ede | ||
|
|
686b96d4b3 | ||
|
|
ee04ac2b98 | ||
|
|
6c04f46e8a | ||
|
|
8774042725 | ||
|
|
c50b0cbfd6 | ||
|
|
5780c0b969 | ||
|
|
36d13d556e | ||
|
|
cb5d8c3468 | ||
|
|
4e8281aefb | ||
|
|
92750ce8f9 | ||
|
|
5f43c23b26 | ||
|
|
746ed4a3c1 | ||
|
|
816a1607a1 | ||
|
|
3bffe61b42 | ||
|
|
33aab936fe | ||
|
|
b5bdd73a90 | ||
|
|
4a79013ef2 | ||
|
|
a8d83b33b4 | ||
|
|
c2a1d6d97e | ||
|
|
68ab185f84 | ||
|
|
82dbac9ac9 | ||
|
|
1e26fb1e3b | ||
|
|
d5f1032bdf | ||
|
|
db64ad1de4 | ||
|
|
ef4ca52285 | ||
|
|
e0d4f0d0f6 | ||
|
|
f7490ffe64 | ||
|
|
9be77a547d | ||
|
|
e4d6e0930f | ||
|
|
2319b67d05 | ||
|
|
94ecaddae2 | ||
|
|
e032e4b9b6 | ||
|
|
b88c46a6a1 | ||
|
|
3c71583324 | ||
|
|
710d506de2 | ||
|
|
c92c7fb9c1 | ||
|
|
3a1fb61847 | ||
|
|
9bce91c20d | ||
|
|
1683c2980e | ||
|
|
5d825537ae | ||
|
|
caa863afe8 | ||
|
|
6881be7555 | ||
|
|
af6fede1b3 | ||
|
|
60153a88e2 | ||
|
|
3faff5dc54 | ||
|
|
819c0ca05a | ||
|
|
459d78cbcb | ||
|
|
eea6b12be6 | ||
|
|
196ebc30dc | ||
|
|
f40667615e | ||
|
|
986e8b926a | ||
|
|
bab99059c5 | ||
|
|
adbb4e7817 | ||
|
|
a40277be4a | ||
|
|
dd302c08b3 | ||
|
|
55784c93de | ||
|
|
3b738100ec | ||
|
|
c7046be642 | ||
|
|
841a9832ec | ||
|
|
6379a66a8d | ||
|
|
f8cea8b075 | ||
|
|
29b68b8c87 | ||
|
|
30ce3f6bf4 | ||
|
|
313121d9a4 | ||
|
|
04c9b28216 | ||
|
|
c9d33c4292 | ||
|
|
b4da045770 | ||
|
|
75acf7b38f |
No files matched your search
@@ -1,2 +0,0 @@
|
||||
* text=auto
|
||||
* text eol=lf
|
||||
@@ -1,28 +1,27 @@
|
||||
**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)**
|
||||
**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)**
|
||||
|
||||
## Describe your changes
|
||||
|
||||
Briefly describe the changes you made and their purpose.
|
||||
Briefly describe the changes you made and their purpose.
|
||||
|
||||
## Write your issue number after "Fixes "
|
||||
|
||||
Fixes #123
|
||||
Fixes #123
|
||||
|
||||
## Please ensure all items are checked off before requesting a review. "Checked off" means you need to add an "x" character between brackets so they turn into checkmarks.
|
||||
|
||||
- [ ] (Do not skip this or your PR will be closed) I deployed the application locally.
|
||||
- [ ] (Do not skip this or your PR will be closed) I have performed a self-reviewing and testing of my code.
|
||||
- [ ] (Do not skip this or your PR will be closed) I have performed a self-review and testing of my code.
|
||||
- [ ] I have included the issue # in the PR.
|
||||
- [ ] I have added i18n support to visible strings (instead of `<div>Add</div>`, use):
|
||||
|
||||
- [ ] I have added i18n support to visible strings (instead of `<div>Add</div>`, use):
|
||||
```Javascript
|
||||
const { t } = useTranslation();
|
||||
<div>{t('add')}</div>
|
||||
```
|
||||
|
||||
- [ ] I have **not** included any files that are not related to my pull request, including package-lock and package-json if dependencies have not changed
|
||||
- [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application).
|
||||
- [ ] I made sure font sizes, color choices etc are all referenced from the theme. I don't have any hardcoded dimensions.
|
||||
- [ ] My PR is granular and targeted to one specific feature.
|
||||
- [ ] I ran `npm run format` in server and client directories, which automatically formats your code.
|
||||
- [ ] I took a screenshot or a video and attached to this PR if there is a UI change.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Test Check (Server)
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-server:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install server dependencies
|
||||
working-directory: server
|
||||
run: npm install
|
||||
|
||||
- name: Run server tests
|
||||
working-directory: server
|
||||
run: npm test
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Build images (feat/saas)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["feat/saas"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
PLATFORMS: linux/amd64,linux/arm64
|
||||
TAG: latest
|
||||
IMAGE_CLIENT: ghcr.io/ajhollid/checkmate-saas-client
|
||||
IMAGE_SERVER: ghcr.io/ajhollid/checkmate-saas-server
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU (for multi-arch emulation)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx (multiarch)
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
driver-opts: |
|
||||
network=host
|
||||
buildkitd-flags: --debug
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USER }}
|
||||
password: ${{ secrets.GHCR_AJHOLLID_TOKEN }}
|
||||
|
||||
- name: Build and push Client (multi-arch)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: client
|
||||
file: client/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_CLIENT }}:${{ env.TAG }}
|
||||
|
||||
- name: Build and push Server (multi-arch)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: server
|
||||
file: server/Dockerfile
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_SERVER }}:${{ env.TAG }}
|
||||
deploy-to-ajhollid:
|
||||
needs: build-and-push
|
||||
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.AJHOLLID_HOST }}
|
||||
username: ${{ secrets.AJHOLLID_USER }}
|
||||
key: ${{ secrets.AJHOLLID_SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd checkmate-saas
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker system prune -af
|
||||
@@ -107,7 +107,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -102,7 +102,6 @@ jobs:
|
||||
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
|
||||
|
||||
+104
-122
@@ -1,138 +1,120 @@
|
||||
name: POEditor Translation Synchronization (disabled)
|
||||
|
||||
# This workflow is intentionally disabled — kept for reference only.
|
||||
# The full definition is preserved commented-out below. To re-enable,
|
||||
# delete the stub job and uncomment the section starting at `on:`.
|
||||
name: POEditor Translation Synchronization
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# For manual triggering
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
||||
required: false
|
||||
default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
||||
format:
|
||||
description: "Export format (key_value_json or json)"
|
||||
required: false
|
||||
default: "key_value_json"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
disabled:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "This workflow is disabled. See comments below for the original definition."
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
|
||||
# ---------------------------------------------------------------------------
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
# on:
|
||||
# # For manual triggering
|
||||
# workflow_dispatch:
|
||||
# inputs:
|
||||
# languages:
|
||||
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
||||
# required: false
|
||||
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
||||
# format:
|
||||
# description: "Export format (key_value_json or json)"
|
||||
# required: false
|
||||
# default: "key_value_json"
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# permissions:
|
||||
# contents: write
|
||||
# pull-requests: write
|
||||
- name: Create package.json for scripts
|
||||
run: |
|
||||
mkdir -p .github/scripts
|
||||
cat > .github/scripts/package.json << EOF
|
||||
{
|
||||
"name": "poeditor-scripts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"fs-extra": "^11.1.1"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# jobs:
|
||||
# sync-translations:
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: write
|
||||
# issues: write
|
||||
# pull-requests: write
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd .github/scripts
|
||||
npm install
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v3
|
||||
# with:
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# fetch-depth: 0
|
||||
- name: Download translations from POEditor
|
||||
env:
|
||||
POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||
LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||
EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
||||
run: |
|
||||
mkdir -p temp
|
||||
node .github/scripts/download-translations.js
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v3
|
||||
# with:
|
||||
# node-version: "22"
|
||||
- name: Verify translation files
|
||||
run: |
|
||||
echo "Verifying translation files..."
|
||||
for file in temp/*.json; do
|
||||
echo "Checking $file"
|
||||
if [ ! -s "$file" ]; then
|
||||
echo "Error: $file is empty or does not exist"
|
||||
exit 1
|
||||
fi
|
||||
# Validate JSON format
|
||||
cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
||||
done
|
||||
echo "All translation files are valid"
|
||||
|
||||
# - name: Create package.json for scripts
|
||||
# run: |
|
||||
# mkdir -p .github/scripts
|
||||
# cat > .github/scripts/package.json << EOF
|
||||
# {
|
||||
# "name": "poeditor-scripts",
|
||||
# "version": "1.0.0",
|
||||
# "private": true,
|
||||
# "type": "module",
|
||||
# "dependencies": {
|
||||
# "axios": "^1.6.0",
|
||||
# "fs-extra": "^11.1.1"
|
||||
# }
|
||||
# }
|
||||
# EOF
|
||||
- name: Copy translations to project
|
||||
run: |
|
||||
mkdir -p client/src/locales
|
||||
cp -r temp/* client/src/locales/
|
||||
echo "Translation files copied to client/src/locales/"
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: |
|
||||
# cd .github/scripts
|
||||
# npm install
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||
|
||||
# - name: Download translations from POEditor
|
||||
# env:
|
||||
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
||||
# run: |
|
||||
# mkdir -p temp
|
||||
# node .github/scripts/download-translations.js
|
||||
- name: Format client code
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
npm run format
|
||||
|
||||
# - name: Verify translation files
|
||||
# run: |
|
||||
# echo "Verifying translation files..."
|
||||
# for file in temp/*.json; do
|
||||
# echo "Checking $file"
|
||||
# if [ ! -s "$file" ]; then
|
||||
# echo "Error: $file is empty or does not exist"
|
||||
# exit 1
|
||||
# fi
|
||||
# # Validate JSON format
|
||||
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
||||
# done
|
||||
# echo "All translation files are valid"
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "feat: translations updated from POEditor"
|
||||
title: "🌐 Updated Translations from POEditor"
|
||||
body: |
|
||||
This PR contains the latest translations from POEditor.
|
||||
|
||||
# - name: Copy translations to project
|
||||
# run: |
|
||||
# mkdir -p client/src/locales
|
||||
# cp -r temp/* client/src/locales/
|
||||
# echo "Translation files copied to client/src/locales/"
|
||||
📅 Update Date: ${{ steps.date.outputs.date }}
|
||||
🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||
|
||||
# - name: Get current date
|
||||
# id: date
|
||||
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||
|
||||
# - name: Format client code
|
||||
# run: |
|
||||
# cd client
|
||||
# npm ci
|
||||
# npm run format
|
||||
|
||||
# - name: Create Pull Request
|
||||
# uses: peter-evans/create-pull-request@v7
|
||||
# with:
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# commit-message: "feat: translations updated from POEditor"
|
||||
# title: "🌐 Updated Translations from POEditor"
|
||||
# body: |
|
||||
# This PR contains the latest translations from POEditor.
|
||||
|
||||
# 📅 Update Date: ${{ steps.date.outputs.date }}
|
||||
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||
|
||||
# ⚠️ Please review the translations and approve the PR if everything looks correct.
|
||||
# branch: translation-update-${{ github.run_number }}
|
||||
# delete-branch: true
|
||||
# base: develop
|
||||
# add-paths: |
|
||||
# client/src/locales/*.json
|
||||
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||
⚠️ Please review the translations and approve the PR if everything looks correct.
|
||||
branch: translation-update-${{ github.run_number }}
|
||||
delete-branch: true
|
||||
base: develop
|
||||
add-paths: |
|
||||
client/src/locales/*.json
|
||||
committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||
author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||
@@ -2,7 +2,7 @@ name: Demo deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "demo" ]
|
||||
branches: ["demo"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
docker-build-and-push-client:
|
||||
@@ -84,8 +84,7 @@ jobs:
|
||||
script: |
|
||||
cd checkmate
|
||||
git pull
|
||||
cd docker/unified
|
||||
cd docker/prod
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker system prune -af
|
||||
@@ -2,7 +2,7 @@ name: Staging deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
branches: ["develop"]
|
||||
|
||||
jobs:
|
||||
docker-build-and-push-client:
|
||||
@@ -78,13 +78,13 @@ jobs:
|
||||
- name: SSH into server and restart container using Docker Compose
|
||||
uses: appleboy/ssh-action@v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.DEMO_SERVER_HOST }}
|
||||
username: ${{ secrets.DEMO_SERVER_USER }}
|
||||
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
|
||||
host: ${{ secrets.STAGING_SERVER_HOST }}
|
||||
username: ${{ secrets.STAGING_SERVER_USER }}
|
||||
key: ${{ secrets.STAGING_SERVER_SSH_KEY }}
|
||||
script: |
|
||||
cd checkmate
|
||||
git pull
|
||||
cd docker/unified
|
||||
cd docker/staging
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
+4
-6
@@ -1,10 +1,8 @@
|
||||
.idea
|
||||
.vscode
|
||||
.VSCodeCounter
|
||||
AGENTS.md
|
||||
*.sh
|
||||
mongo
|
||||
node_modules/
|
||||
docs/architecture
|
||||
docs/reviews
|
||||
docs/todo
|
||||
docs/frontend
|
||||
notes
|
||||
docker-compose.yml
|
||||
mongo
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
echo "Running lint-staged..."
|
||||
cd client && npx lint-staged && cd ..
|
||||
cd server && npx lint-staged && cd ..
|
||||
@@ -1,195 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Checkmate is an open-source uptime and infrastructure monitoring application. It monitors server hardware, uptime, response times, and incidents with real-time alerts. The companion agent [Capture](https://github.com/bluewave-labs/capture) provides infrastructure metrics (CPU, RAM, disk, temperature).
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Client (React/Vite)
|
||||
```bash
|
||||
cd client
|
||||
npm install
|
||||
npm run dev # Start dev server at http://localhost:5173
|
||||
npm run build # TypeScript check + production build
|
||||
npm run lint # ESLint (strict, max-warnings 0)
|
||||
npm run format # Prettier formatting
|
||||
npm run format-check # Check formatting
|
||||
```
|
||||
|
||||
### Server (Node.js/Express)
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # Start with hot-reload (nodemon + tsx) at http://localhost:52345
|
||||
npm run build # TypeScript compile + path alias resolution
|
||||
npm run test # Run Mocha tests with c8 coverage
|
||||
npm run lint # ESLint v9
|
||||
npm run lint-fix # Auto-fix lint issues
|
||||
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
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### Server `.env` (minimum required)
|
||||
```env
|
||||
CLIENT_HOST="http://localhost:5173"
|
||||
JWT_SECRET="my_secret_key_change_this"
|
||||
DB_CONNECTION_STRING="mongodb://localhost:27017/uptime_db"
|
||||
TOKEN_TTL="99d"
|
||||
ORIGIN="localhost"
|
||||
LOG_LEVEL="debug"
|
||||
```
|
||||
|
||||
### Client `.env`
|
||||
```env
|
||||
VITE_APP_API_BASE_URL="http://localhost:52345/api/v1"
|
||||
VITE_APP_LOG_LEVEL="debug"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||
|
||||
### Backend Layers
|
||||
```
|
||||
server/src/
|
||||
├── controllers/ # Route handlers (authController, monitorController, etc.)
|
||||
├── service/ # Business logic
|
||||
│ ├── business/ # Core monitoring logic
|
||||
│ ├── infrastructure/ # Server/system utilities
|
||||
│ └── system/ # App-level settings
|
||||
├── db/
|
||||
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
|
||||
│ ├── migration/ # Database migrations (run on startup)
|
||||
│ └── modules/ # Database-specific modules
|
||||
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
||||
├── routes/v1/ # API route definitions
|
||||
├── validation/ # Joi input validation schemas
|
||||
└── repositories/ # Data access layer
|
||||
```
|
||||
|
||||
### Frontend Structure
|
||||
```
|
||||
client/src/
|
||||
├── Components/ # Reusable UI components
|
||||
├── Pages/ # Page components (Auth, Uptime, Infrastructure, Incidents, etc.)
|
||||
├── Features/ # Redux slices (Auth, UI)
|
||||
├── Hooks/ # Custom React hooks
|
||||
├── Utils/ # Utilities (NetworkService.js is main API client)
|
||||
├── Validation/ # Input validation
|
||||
└── locales/ # i18n translations
|
||||
```
|
||||
|
||||
### API
|
||||
- Base URL: `/api/v1`
|
||||
- Documentation: `http://localhost:52345/api-docs` (Swagger UI)
|
||||
- OpenAPI spec: `/server/openapi.json`
|
||||
|
||||
### Key Technologies
|
||||
- **State Management**: Redux Toolkit + Redux-Persist
|
||||
- **Data Fetching**: SWR + Axios
|
||||
- **Database**: MongoDB with Mongoose ODM
|
||||
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
||||
- **i18n**: i18next + react-i18next (translations via PoEditor)
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture Patterns
|
||||
|
||||
### Repository Pattern & Separation of Concerns
|
||||
|
||||
The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access:
|
||||
|
||||
```
|
||||
Request → Controller → Service → Repository → MongoDB (Mongoose)
|
||||
```
|
||||
|
||||
- **Controllers** (`/controllers`) handle HTTP concerns only: parsing request params, calling the appropriate service, and returning a response via the `responseHandler` middleware. They contain no business logic.
|
||||
- **Services** (`/service/business`) contain all business logic: deciding whether an incident should be created, whether a notification should fire, what state a monitor is in, etc.
|
||||
- **Repositories** (`/repositories`) are the sole layer that talks to MongoDB through Mongoose. They expose clean, reusable query methods (e.g. `findByMonitorId`, `createCheck`) so that services never construct raw DB queries directly.
|
||||
|
||||
This separation makes each layer independently testable and keeps Mongoose-specific code out of business logic. When adding a new feature, the pattern to follow is: add a repository method for any new DB query, call it from a service, and expose it via a controller route.
|
||||
|
||||
### Monitoring Flow: From Check to Notification
|
||||
|
||||
Background monitoring runs on a scheduled queue, not on the HTTP request cycle. The high-level flow for uptime monitoring is:
|
||||
|
||||
```
|
||||
Pulse (cron) → BullMQ Job → StatusService
|
||||
├── performs HTTP/port/ping check
|
||||
├── saves Check via CheckRepository
|
||||
├── evaluates monitor state change
|
||||
│ └── calls IncidentService (create / resolve incident)
|
||||
└── calls NotificationService (email, Slack, Discord, webhook)
|
||||
```
|
||||
|
||||
1. **Pulse** (cron scheduler) enqueues a job into a **BullMQ** queue for each active monitor at its configured interval.
|
||||
2. A **BullMQ worker** picks up the job and calls `StatusService`, which performs the actual check (HTTP request, TCP port probe, ping, etc.).
|
||||
3. The result is persisted as a `Check` document via the repository layer.
|
||||
4. `StatusService` compares the new result against the monitor's previous state. If the monitor transitions from up → down (or down → up), it delegates to `IncidentService` to open or resolve an `Incident` document.
|
||||
5. On a state change, `NotificationService` reads the monitor's configured `Notification` documents and dispatches alerts to all enabled channels (email, Discord, Slack, webhooks).
|
||||
|
||||
### Queue System (BullMQ + Redis)
|
||||
|
||||
Redis serves two roles: job queue storage for BullMQ and ephemeral caching. BullMQ manages concurrency, retries, and backpressure for monitoring jobs, ensuring checks are processed reliably even under load.
|
||||
|
||||
- Each monitor type (HTTP, port, ping, infrastructure) maps to its own queue worker so failures in one type don't block others.
|
||||
- Job scheduling interval is driven by the `interval` field on the `Monitor` model.
|
||||
- Failed jobs are retried with configurable backoff before being moved to a dead-letter state.
|
||||
- Redis is also used to cache frequently read data (e.g. aggregated stats) to reduce MongoDB query pressure.
|
||||
|
||||
When working on anything related to check scheduling, incident lifecycle, or notifications, trace the flow starting from the relevant BullMQ worker rather than from the controller layer.
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Internationalization
|
||||
All user-facing strings must use the translation function:
|
||||
```javascript
|
||||
t('your.key') // Never hardcode UI strings
|
||||
```
|
||||
|
||||
### Branching
|
||||
- Always branch from `develop` (not master)
|
||||
- Use descriptive names: `feat/add-alerts`, `fix/login-error`
|
||||
- PRs target `develop` branch
|
||||
|
||||
### Formatting
|
||||
- **Client**: Prettier with `printWidth: 90`, tabs, double quotes
|
||||
- **Server**: Prettier with `printWidth: 150`, tabs, double quotes
|
||||
- Both use ESLint with strict settings
|
||||
|
||||
### Testing
|
||||
Server tests use Mocha + Chai + Sinon:
|
||||
```bash
|
||||
npm test # Run all tests with coverage
|
||||
npm test -- --grep "pattern" # Run specific tests
|
||||
```
|
||||
Test files: `server/tests/**/*.test.js`
|
||||
|
||||
## Database Models
|
||||
|
||||
Key Mongoose models in `/server/src/db/models/`:
|
||||
- **Monitor** - Monitoring configuration (website, infrastructure, port, etc.)
|
||||
- **Check** - Individual monitoring check results
|
||||
- **Incident** - Downtime incidents
|
||||
- **User** - User accounts
|
||||
- **Team** - Team/workspace management
|
||||
- **StatusPage** - Public status pages
|
||||
- **Notification** - Alert configuration (email, Discord, Slack, webhooks)
|
||||
- **MaintenanceWindow** - Scheduled maintenance periods
|
||||
- **AppSettings** - Global application settings
|
||||
+69
-69
@@ -1,69 +1,69 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in the **checkmate** project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community, especially during Hacktoberfest, where we encourage new contributors to join and feel supported.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, especially during Hacktoberfest to ensure a positive experience for all contributors.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces, and it also applies when an individual is officially representing the project or its community in public spaces. Examples of representing our project include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@bluewavelabs.ca. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project team members are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
**Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
---
|
||||
|
||||
By following this Code of Conduct, we can ensure a welcoming and inclusive environment, encouraging new contributors to engage with the project and the community. Thank you for helping make **checkmate** a positive space for all!
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in the **bluewave-uptime** project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community, especially during Hacktoberfest, where we encourage new contributors to join and feel supported.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, especially during Hacktoberfest to ensure a positive experience for all contributors.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces, and it also applies when an individual is officially representing the project or its community in public spaces. Examples of representing our project include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@bluewavelabs.ca. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All project team members are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
**Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
---
|
||||
|
||||
By following this Code of Conduct, we can ensure a welcoming and inclusive environment, encouraging new contributors to engage with the project and the community. Thank you for helping make **bluewave-uptime** a positive space for all!
|
||||
+6
-37
@@ -10,31 +10,6 @@ PS: **We work closely with contributors on our [Discord channel](https://discord
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🚀 Quick Setup Checklist
|
||||
|
||||
Before you dive in, make sure you have these installed:
|
||||
|
||||
```bash
|
||||
# Check Node.js (v20+ required)
|
||||
node --version
|
||||
|
||||
# Check npm
|
||||
npm --version
|
||||
|
||||
# Check Docker
|
||||
docker --version
|
||||
|
||||
# Check Git
|
||||
git --version
|
||||
```
|
||||
|
||||
**New to contributing?** Start here:
|
||||
1. Pick a [`good-first-issue`](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
|
||||
2. Comment that you'd like to work on it
|
||||
3. Follow the [setup guide](#set-up-checkmate-locally) below
|
||||
4. Join our [Discord](https://discord.com/invite/NAb6H3UTjK) if you get stuck
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [How do I...?](#how-do-i)
|
||||
@@ -91,31 +66,25 @@ cd Checkmate
|
||||
Navigate to the Docker dev directory:
|
||||
|
||||
```bash
|
||||
cd docker/dev
|
||||
cd server/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
|
||||
docker run -d -p 27017:27017 -v $(pwd)/mongo/data:/data/db --name uptime_database_mongo uptime_database_mongo
|
||||
```
|
||||
|
||||
Navigate back to the root directory:
|
||||
|
||||
```bash
|
||||
cd ../..
|
||||
cd ../../..
|
||||
```
|
||||
|
||||
#### Step 3: Set Up the Backend (Server)
|
||||
@@ -192,7 +161,7 @@ Start the frontend:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The client will run at `http://localhost:5173`.
|
||||
The client will run at `http://localhost:5173`.
|
||||
|
||||
#### Step 5: Access the Application
|
||||
|
||||
@@ -240,7 +209,7 @@ docker rm uptime_database_mongo
|
||||
|
||||
**Need more help?**
|
||||
|
||||
- Check the [full documentation](https://checkmate.so/docs)
|
||||
- Check the [full documentation](https://docs.checkmate.so)
|
||||
- Ask on [Discord](https://discord.com/invite/NAb6H3UTjK)
|
||||
|
||||
### Start contributing code?
|
||||
@@ -316,7 +285,7 @@ If one or more of these are missing, we may ask you to update your pull request
|
||||
- `master` is used for stable releases.
|
||||
- Use descriptive branch names, like `fix/login-error` or `feat/add-alerts`.
|
||||
- Make sure that you are using the latest version.
|
||||
- Make sure you run the code locally. The Checkmate [documentation](https://checkmate.so/docs) covers it.
|
||||
- Make sure you run the code locally. The Checkmate [documentation](https://docs.checkmate.so) covers it.
|
||||
- Find out if the functionality is already covered, maybe by an individual configuration.
|
||||
- Perform a [search](/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
|
||||
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@ Ensure you execute `npm run format` before submitting your pull requests in both
|
||||
|
||||
### Keep PRs focused
|
||||
|
||||
It may be tempting to address a bug you suddenly remembered or make some tiny adjustments in some component that bothers you, but don’t! Keep all commits in your pull request fully focused on the specific feature you are working on. Open up another PR if you want to fix a bug or work on another feature.
|
||||
It may be tempting to address a bug you suddenly remembered or make some tiny adjustments in some component that bothers you, but don’t! Keep all commits in your pull request fully focused on the specific feature you are working on. Open up another PR if you want to fix a big or work on another feature.
|
||||
+4
-4
@@ -49,11 +49,11 @@ Puedes ver la última versión de [Checkmate](https://checkmate-demo.bluewavelab
|
||||
|
||||
## Guía del usuario
|
||||
|
||||
Las instrucciones de uso se pueden encontrar [aquí](https://checkmate.so/docs). Todavía está en desarrollo y parte de la información puede estar desactualizada ya que continuamente añadimos funciones cada semana. ¡Ten por seguro que estamos haciendo lo mejor posible! :)
|
||||
Las instrucciones de uso se pueden encontrar [aquí](https://docs.checkmate.so/checkmate-2.1). Todavía está en desarrollo y parte de la información puede estar desactualizada ya que continuamente añadimos funciones cada semana. ¡Ten por seguro que estamos haciendo lo mejor posible! :)
|
||||
|
||||
## Instalación
|
||||
|
||||
Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://checkmate.so/docs/getting-started/installation).
|
||||
Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
|
||||
|
||||
Alternativamente, también puedes usar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) o [Pikapods](https://www.pikapods.com/) para desplegar rápidamente una instancia de Checkmate. Si deseas monitorear tu infraestructura de servidores, necesitarás el agente [Capture](https://github.com/bluewave-labs/capture). El repositorio de Capture también contiene las instrucciones de instalación.
|
||||
|
||||
@@ -76,7 +76,7 @@ También puedes ver el consumo de memoria de MongoDB y Redis en el mismo servido
|
||||
Si tienes alguna pregunta, sugerencia o comentario, tienes varias opciones:
|
||||
|
||||
- [Canal de Discord](https://discord.gg/NAb6H3UTjK)
|
||||
- [Discusiones en GitHub](https://github.com/bluewave-labs/checkmate/discussions)
|
||||
- [Discusiones en GitHub](https://github.com/bluewave-labs/bluewave-uptime/discussions)
|
||||
- [Grupo en Reddit](https://www.reddit.com/r/CheckmateMonitoring/)
|
||||
|
||||
¡No dudes en hacer preguntas o compartir tus ideas, nos encantaría saber de ti!
|
||||
@@ -147,7 +147,7 @@ Cómo contribuir:
|
||||
|
||||
0. Dale una estrella al repositorio :)
|
||||
1. Revisa la [guía para contribuidores](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Se anima a los nuevos a revisar las etiquetas `good-first-issue`.
|
||||
2. Consulta la [estructura del proyecto](https://deepwiki.com/bluewave-labs/Checkmate#system-architecture) y la [visión general](https://deepwiki.com/bluewave-labs/Checkmate#overview).
|
||||
2. Consulta la [estructura del proyecto](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) y la [visión general](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
|
||||
3. Lee una estructura detallada de [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) si deseas profundizar en la arquitectura.
|
||||
4. Abre un issue si crees que has encontrado un error.
|
||||
5. Revisa los issues con la etiqueta `good-first-issue` si eres nuevo.
|
||||
|
||||
@@ -1,187 +1,50 @@
|
||||
<p align=center> <a href="https://trendshift.io/repositories/12443" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12443" alt="bluewave-labs%2Fcheckmate | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a></p>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://www.bestpractices.dev/projects/9901)
|
||||
[](https://deepwiki.com/bluewave-labs/checkmate)
|
||||
# Checkmate
|
||||
|
||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||
A full‑stack uptime and infrastructure monitoring app. The repository contains an Express/TypeScript API and a React/TypeScript client.
|
||||
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
||||
## Project Structure
|
||||
- `server/` – Node.js API (TypeScript, ESM)
|
||||
- Source: `server/src`
|
||||
- Routes mounted under `/api/v1/*` via `server/src/init/routes.ts`
|
||||
- Tests: `server/tests`
|
||||
- Build output: `server/dist`
|
||||
- `client/` – React + TypeScript (Vite)
|
||||
- Source: `client/src`
|
||||
- Public assets: `client/public`
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
## Key Features
|
||||
- JWT authentication and permission checks (team/org).
|
||||
- Monitors API: create, list (with sorting/pagination), get checks, toggle pause, update, delete.
|
||||
- Import/Export monitors (`GET /api/v1/monitors/export`, `POST /api/v1/monitors/import`).
|
||||
- Notification channels and diagnostics endpoints.
|
||||
- System settings (SMTP) with env‑first email transport fallback to DB settings.
|
||||
|
||||
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
|
||||
## Getting Started
|
||||
1. Prerequisites: Node 18+, MongoDB
|
||||
2. Environment
|
||||
- Server: create `server/.env` with at least:
|
||||
- `MONGODB_URI=mongodb://localhost:27017/saas`
|
||||
- `JWT_SECRET=replace_me`
|
||||
- `ORIGIN=http://localhost:5173`
|
||||
- Optional SMTP: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`
|
||||
3. Install
|
||||
- `cd server && npm ci`
|
||||
- `cd ../client && npm ci`
|
||||
4. Run
|
||||
- API: `cd server && npm run dev` (listens on `http://localhost:52345`)
|
||||
- Client: `cd client && npm run dev` (Vite dev server)
|
||||
|
||||
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
|
||||
## Scripts
|
||||
- Server
|
||||
- `npm run dev` – Start API in watch mode
|
||||
- `npm run build` – Type‑check and compile (tsc + tsc‑alias)
|
||||
- `npm test` / `npm run test:coverage` – Jest tests and coverage
|
||||
- Client
|
||||
- `npm run dev` – Start Vite dev server
|
||||
- `npm run build` – Type‑check and build
|
||||
- `npm run preview` – Preview production build
|
||||
|
||||
Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/capture), to retrieve data from remote servers. While Capture is not required to run Checkmate, it provides additional insights about your servers' CPU, RAM, disk, and temperature status. Capture can run on Linux, Windows, Mac, Raspberry Pi, or any device that can run Go.
|
||||
|
||||
Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks.
|
||||
|
||||
**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).**
|
||||
|
||||
## 📚 Table of contents
|
||||
|
||||
- [📦 Demo](#demo)
|
||||
- [🔗 User's guide](#users-guide)
|
||||
- [🛠️ Installation](#installation)
|
||||
- [🚀 Performance](#performance)
|
||||
- [💚 Questions & Ideas](#questions--ideas)
|
||||
- [🧩 Features](#features)
|
||||
- [🏗️ Screenshots](#screenshots)
|
||||
- [🏗️ Tech stack](#tech-stack)
|
||||
- [🔗 A few links](#a-few-links)
|
||||
- [🤝 Contributing](#contributing)
|
||||
- [💰 Our sponsors](#our-sponsors)
|
||||
|
||||
|
||||
## Demo
|
||||
|
||||
You can see the latest build of [Checkmate](https://demo.checkmate.so/) in action.
|
||||
|
||||
The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
|
||||
|
||||
## User's guide
|
||||
|
||||
Usage instructions can be found [here](https://checkmate.so/docs).
|
||||
|
||||
## Prerequisites
|
||||
- [Docker](https://www.docker.com/) installed
|
||||
- [Git](https://git-scm.com/) installed
|
||||
|
||||
## Installation
|
||||
|
||||
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
||||
|
||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
|
||||
### Using a Custom CA
|
||||
|
||||
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
||||
|
||||
For more documentation, see the [docs directory](./docs/).
|
||||
|
||||
## Performance
|
||||
|
||||
Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
|
||||
|
||||

|
||||
|
||||
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
|
||||
|
||||

|
||||
|
||||
## Questions & Ideas
|
||||
|
||||
If you have any questions, suggestions or comments, you have several options:
|
||||
|
||||
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
|
||||
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
|
||||
|
||||
Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
|
||||
## Features
|
||||
|
||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||
- Website monitoring
|
||||
- Page speed monitoring
|
||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||
- Selective disk monitoring with mountpoint selection
|
||||
- Docker monitoring
|
||||
- Ping monitoring
|
||||
- SSL monitoring
|
||||
- Port monitoring
|
||||
- Game server monitoring (3.0)
|
||||
- Incidents at a glance
|
||||
- Status pages
|
||||
- E-mail, Webhooks, Discord and Slack notifications
|
||||
- Scheduled maintenance
|
||||
- JSON query monitoring
|
||||
- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan)
|
||||
|
||||
**Short term roadmap:**
|
||||
|
||||
- Plugins that will help Checkmate get any information from a remote service (e.g database, etc)
|
||||
- Better notifications
|
||||
- Network monitoring
|
||||
- ..and a few more features
|
||||
|
||||
If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
|
||||
|
||||
## Monitor Lifecycle
|
||||
|
||||
1. A monitor executes a check (HTTP / ping / port / hardware via Capture agent)
|
||||
2. The result is stored (success/failure + response time)
|
||||
3. Recent check results are evaluated against the monitor's configured status change threshold
|
||||
4. If the monitor's status change threshold is met and the current status is not equal to the previous status, the monitor's state changes (e.g. `initializing`, `up`, `down`, `breached`)
|
||||
5. Upon a state change: an incident is either created or resolved, depending on the monitor's current status
|
||||
6. Notifications are triggered based on configuration
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p>
|
||||
<img width="1628" alt="image" src="https://github.com/user-attachments/assets/2eff6464-0738-4a32-9312-26e1e8e86275" />
|
||||
</p>
|
||||
<p>
|
||||
<img width="1656" alt="image" src="https://github.com/user-attachments/assets/616c3563-c2a7-4ee4-af6c-7e6068955d1a" />
|
||||
</p>
|
||||
<p>
|
||||
</p><img width="1652" alt="image" src="https://github.com/user-attachments/assets/7912d7cf-0d0e-4f26-aa5c-2ad7170b5c99" />
|
||||
</p>
|
||||
<p>
|
||||
<img width="1652" alt="image" src="https://github.com/user-attachments/assets/08c2c6ac-3a2f-44d1-a229-d1746a3f9d16" />
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
## Tech stack
|
||||
|
||||
- [ReactJs](https://react.dev/)
|
||||
- [MUI (React framework)](https://mui.com/)
|
||||
- [Node.js](https://nodejs.org/en)
|
||||
- [MongoDB](https://mongodb.com)
|
||||
- [Recharts](https://recharts.org)
|
||||
- Lots of other open source components!
|
||||
|
||||
## A few links
|
||||
|
||||
- If you would like to support us, please consider giving it a ⭐ and click on "watch".
|
||||
- Have a question or suggestion for the roadmap/featureset? Check our [Discord channel](https://discord.gg/NAb6H3UTjK) or [Discussions](https://github.com/bluewave-labs/checkmate/discussions) forum.
|
||||
- Need a ping when there's a new release? Use [Newreleases](https://newreleases.io/), a free service to track releases.
|
||||
- Watch a Checkmate [installation and usage video](https://www.youtube.com/watch?v=GfFOc0xHIwY)
|
||||
|
||||
## Contributing
|
||||
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
|
||||
|
||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
|
||||
|
||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
||||
|
||||
Here's how you can contribute:
|
||||
|
||||
0. Star this repo :)
|
||||
1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag.
|
||||
2. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
|
||||
3. Open an issue if you believe you've encountered a bug.
|
||||
4. Check for good-first-issue's if you are a newcomer.
|
||||
5. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
|
||||
6. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
|
||||
|
||||
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
|
||||
</a>
|
||||
|
||||
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
|
||||
|
||||
## Our sponsors
|
||||
|
||||
Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
|
||||
|
||||
If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
|
||||
## Notes
|
||||
- The API expects valid JWTs on most routes; see `server/src/routes` for middleware usage.
|
||||
- Mongo connection retries are handled in `server/src/db/MongoDB.ts`.
|
||||
- Email transport prioritizes env config; if unset, it uses values from system settings.
|
||||
@@ -1,17 +0,0 @@
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: checkmate-chart
|
||||
description: A Helm chart for Checkmate App
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "2.3"
|
||||
@@ -1,116 +0,0 @@
|
||||
# Kubernetes Installation Guide for Checkmate
|
||||
|
||||
This guide walks you through deploying Checkmate on your Kubernetes cluster using Helm.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Kubernetes cluster
|
||||
- Helm CLI installed and configured
|
||||
- `kubectl` configured to access your cluster
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Clone the repo and navigate to the Helm chart
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bluewave-labs/checkmate.git
|
||||
cd checkmate/charts/helm/checkmate
|
||||
```
|
||||
|
||||
### 2. Customize values.yaml
|
||||
Edit `values.yaml` to update:
|
||||
- `client.ingress.host` and `server.ingress.host` with your domain names
|
||||
- `server.protocol` (usually http or https)
|
||||
- **If upgrading**: Migrate persistence settings from flat structure to nested:
|
||||
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
|
||||
- Old: `persistence.redisSize` → New: `persistence.redis.size`
|
||||
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
|
||||
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
|
||||
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
|
||||
|
||||
### 3. Deploy the Helm chart
|
||||
```bash
|
||||
helm install checkmate ./charts/helm/checkmate
|
||||
```
|
||||
This will deploy the client, server, MongoDB, and Redis components.
|
||||
|
||||
### 4. Verify the deployment
|
||||
Check pods and services:
|
||||
```bash
|
||||
kubectl get pods
|
||||
kubectl get svc
|
||||
```
|
||||
|
||||
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
|
||||
|
||||
## Enabling TLS/HTTPS with cert-manager
|
||||
|
||||
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
|
||||
|
||||
### Prerequisites
|
||||
- cert-manager installed in your cluster
|
||||
- A ClusterIssuer or Issuer configured (e.g., `letsencrypt-prod`)
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `values.yaml` to enable TLS (and update protocols to https):
|
||||
|
||||
```yaml
|
||||
client:
|
||||
protocol: https
|
||||
ingress:
|
||||
enabled: true
|
||||
host: checkmate.example.com
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: checkmate-client-tls
|
||||
|
||||
server:
|
||||
protocol: https
|
||||
ingress:
|
||||
enabled: true
|
||||
host: checkmate.example.com
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: checkmate-server-tls
|
||||
```
|
||||
|
||||
### Alternative: Using --set flags
|
||||
|
||||
You can also enable TLS during installation using Helm's `--set` flags:
|
||||
|
||||
```bash
|
||||
helm install checkmate ./charts/helm/checkmate \
|
||||
--set client.protocol=https \
|
||||
--set server.protocol=https \
|
||||
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set client.ingress.tls.enabled=true \
|
||||
--set client.ingress.tls.secretName=checkmate-client-tls \
|
||||
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||
--set server.ingress.tls.enabled=true \
|
||||
--set server.ingress.tls.secretName=checkmate-server-tls
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
After deployment, cert-manager will automatically create the TLS secrets. You can verify the certificate status:
|
||||
|
||||
```bash
|
||||
# Check certificates
|
||||
kubectl get certificates
|
||||
|
||||
# Check certificate details
|
||||
kubectl describe certificate checkmate-client-tls
|
||||
kubectl describe certificate checkmate-server-tls
|
||||
|
||||
# Verify the secrets were created
|
||||
kubectl get secrets | grep checkmate-tls
|
||||
```
|
||||
|
||||
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
|
||||
@@ -1,44 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-client
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-client
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-client
|
||||
spec:
|
||||
{{- with .Values.client.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.client.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: client
|
||||
image: {{ .Values.client.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.client.port }}
|
||||
env:
|
||||
- name: UPTIME_APP_API_BASE_URL
|
||||
value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
|
||||
- name: UPTIME_APP_CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
volumeMounts:
|
||||
- name: config-volume
|
||||
mountPath: /etc/nginx/conf.d/default.conf
|
||||
subPath: default.conf
|
||||
{{- with .Values.client.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: checkmate-server-nginx-cm
|
||||
@@ -1,31 +0,0 @@
|
||||
{{- if .Values.client.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-client-ingress
|
||||
{{- if .Values.client.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := .Values.client.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.client.ingress.className }}
|
||||
{{- if .Values.client.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.client.ingress.host }}
|
||||
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.client.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-client
|
||||
port:
|
||||
number: {{ .Values.client.port }}
|
||||
{{- end }}
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-client
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-client
|
||||
ports:
|
||||
- port: {{ .Values.client.port }}
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-mongodb
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-mongodb
|
||||
ports:
|
||||
- port: {{ .Values.mongodb.port }}
|
||||
@@ -1,44 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: checkmate-mongodb
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-mongodb
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-mongodb
|
||||
spec:
|
||||
{{- with .Values.mongodb.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.mongodb.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: mongodb
|
||||
image: {{ .Values.mongodb.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.mongodb.port }}
|
||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||
volumeMounts:
|
||||
- name: checkmate-mongo-persistent-storage
|
||||
mountPath: /data/db
|
||||
{{- with .Values.mongodb.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: checkmate-mongo-persistent-storage
|
||||
spec:
|
||||
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.mongo.size | default "5Gi" | quote }}
|
||||
@@ -1,48 +0,0 @@
|
||||
{{- if eq .Values.client.ingress.host "change_me" }}
|
||||
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- if eq .Values.server.ingress.host "change_me" }}
|
||||
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $serverProtocol := .Values.server.protocol }}
|
||||
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
|
||||
{{- fail "server.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $clientProtocol := .Values.client.protocol }}
|
||||
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
|
||||
{{- fail "client.protocol must be either 'http' or 'https'" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
|
||||
{{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
|
||||
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
|
||||
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* If client runs on https, API must also be https to avoid mixed content */}}
|
||||
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
|
||||
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
|
||||
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
|
||||
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
|
||||
|
||||
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
|
||||
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
|
||||
|
||||
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
|
||||
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
|
||||
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
|
||||
@@ -1,11 +0,0 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-redis
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-redis
|
||||
ports:
|
||||
- port: {{ .Values.redis.port }}
|
||||
{{- end }}
|
||||
@@ -1,33 +0,0 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: checkmate-redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: {{ .Values.redis.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.redis.port }}
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: checkmate-redis-persistent-storage
|
||||
spec:
|
||||
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
|
||||
{{- end }}
|
||||
@@ -1,15 +0,0 @@
|
||||
{{- $secrets := .Values.secrets }}
|
||||
|
||||
{{- if or (not $secrets.JWT_SECRET) (eq $secrets.JWT_SECRET "change_me") }}
|
||||
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
|
||||
{{- end }}
|
||||
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: checkmate-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- range $key, $value := $secrets }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
@@ -1,37 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: checkmate-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkmate-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkmate-server
|
||||
spec:
|
||||
{{- with .Values.server.affinity }}
|
||||
affinity:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.server.tolerations }}
|
||||
tolerations:
|
||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: server
|
||||
image: {{ .Values.server.image }}
|
||||
ports:
|
||||
- containerPort: {{ .Values.server.port }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: checkmate-secrets
|
||||
{{- with .Values.server.resources }}
|
||||
resources:
|
||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: CLIENT_HOST
|
||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||
@@ -1,38 +0,0 @@
|
||||
{{- if .Values.server.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: checkmate-server-ingress
|
||||
{{- if .Values.server.ingress.annotations }}
|
||||
annotations:
|
||||
{{- range $key, $value := .Values.server.ingress.annotations }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{/*#annotations:
|
||||
#nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
#nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.server.ingress.className }}
|
||||
{{- if .Values.server.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.server.ingress.host }}
|
||||
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.server.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkmate-server
|
||||
port:
|
||||
number: {{ .Values.server.port }}
|
||||
{{- end }}
|
||||
@@ -1,41 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: checkmate-server-nginx-cm
|
||||
data:
|
||||
default.conf: |
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
|
||||
server_name checkmate-demo.bluewavelabs.ca;
|
||||
server_tokens off;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# location /api/ {
|
||||
# proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
|
||||
location /api-docs/ {
|
||||
proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: checkmate-server
|
||||
spec:
|
||||
selector:
|
||||
app: checkmate-server
|
||||
ports:
|
||||
- port: {{ .Values.server.port }}
|
||||
@@ -1,70 +0,0 @@
|
||||
client:
|
||||
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
|
||||
port: 80
|
||||
protocol: http
|
||||
ingress:
|
||||
enabled: true
|
||||
host: change_me
|
||||
className: nginx
|
||||
annotations: {}
|
||||
# Example annotations for cert-manager:
|
||||
# annotations:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
|
||||
# Note: when enabling TLS, also set client.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
server:
|
||||
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
|
||||
port: 52345
|
||||
protocol: http
|
||||
ingress:
|
||||
enabled: true
|
||||
host: change_me
|
||||
className: nginx
|
||||
annotations: {}
|
||||
# Example annotations for cert-manager:
|
||||
# annotations:
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
tls:
|
||||
enabled: false
|
||||
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
|
||||
# Note: when enabling TLS, also set server.protocol: https and add
|
||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||
|
||||
redis:
|
||||
enabled: false
|
||||
image: redis:7.2
|
||||
port: 6379
|
||||
|
||||
mongodb:
|
||||
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
|
||||
port: 27017
|
||||
|
||||
secrets:
|
||||
JWT_SECRET: change_me
|
||||
# REFRESH_TOKEN_SECRET: change_me
|
||||
# SYSTEM_EMAIL_ADDRESS: test@example.com
|
||||
# SYSTEM_EMAIL_PASSWORD: change_me
|
||||
# SYSTEM_EMAIL_HOST: smtp.example.com
|
||||
# SYSTEM_EMAIL_PORT: "587"
|
||||
# PAGESPEED_API_KEY: change_me
|
||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
|
||||
CLIENT_HOST: change_me
|
||||
# REDIS_HOST: redis
|
||||
# REDIS_PORT: "6379"
|
||||
# DB_TYPE: MongoDB
|
||||
# TOKEN_TTL: 99d
|
||||
# REFRESH_TOKEN_TTL: 99d
|
||||
|
||||
persistence:
|
||||
mongo:
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
redis:
|
||||
size: 1Gi
|
||||
storageClass: ""
|
||||
@@ -1,14 +0,0 @@
|
||||
language: "en-CA"
|
||||
tone_instructions: "His palms are sweaty, knees weak, arms are heavy There’s vomit on his sweater already, mom’s spaghetti"
|
||||
early_access: false
|
||||
reviews:
|
||||
profile: "chill"
|
||||
request_changes_workflow: false
|
||||
high_level_summary: false
|
||||
poem: false
|
||||
review_status: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
chat:
|
||||
auto_reply: false
|
||||
@@ -1,3 +0,0 @@
|
||||
Client/node_modules/
|
||||
Server/node_modules/
|
||||
Server/*.env
|
||||
@@ -1,4 +0,0 @@
|
||||
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
|
||||
VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
|
||||
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
|
||||
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
|
||||
@@ -1,23 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
|
||||
settings: { react: { version: "18.2" } },
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react/jsx-no-target-blank": "off",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"react/no-unescaped-entities": "off",
|
||||
},
|
||||
globals: {
|
||||
__APP_VERSION__: "readonly",
|
||||
process: "readonly",
|
||||
},
|
||||
};
|
||||
+2
-3
@@ -24,9 +24,8 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
||||
.env.*
|
||||
|
||||
# Scripts
|
||||
*.sh
|
||||
!env.sh
|
||||
.env.development
|
||||
!env.sh
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"printWidth": 90,
|
||||
"useTabs": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": false,
|
||||
"bracketSpacing": true,
|
||||
"proseWrap": "preserve",
|
||||
"bracketSameLine": false,
|
||||
"singleAttributePerLine": true,
|
||||
"semi": true,
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"arrowParens": "always",
|
||||
"trailingComma": "es5",
|
||||
"htmlWhitespaceSensitivity": "css"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS build
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Assets-only image: no web server. Contains built files at /dist.
|
||||
FROM --platform=$TARGETPLATFORM busybox:stable
|
||||
WORKDIR /
|
||||
COPY --from=build /app/dist /dist
|
||||
# No CMD provided intentionally — this image is for copying/volume population.
|
||||
@@ -1,18 +0,0 @@
|
||||
Checkmate
|
||||
Copyright (c) BlueWave Labs
|
||||
|
||||
This product includes assets developed by third parties.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
King chess-piece silhouette used in the Checkmate logo
|
||||
(client/src/assets/icons/checkmate-icon.svg and
|
||||
client/public/checkmate_favicon.svg)
|
||||
is adapted from "Chess klt45.svg" by Cburnett, available at:
|
||||
https://commons.wikimedia.org/wiki/File:Chess_klt45.svg
|
||||
Licensed under Creative Commons Attribution-ShareAlike 3.0 Unported
|
||||
(CC BY-SA 3.0): https://creativecommons.org/licenses/by-sa/3.0/
|
||||
|
||||
The original artwork has been recolored from the white-with-black-stroke
|
||||
chess piece to the Checkmate brand green (#13715B) for use as a logo.
|
||||
Modifications: stroke color change only; geometry unchanged.
|
||||
+73
-1
@@ -1 +1,73 @@
|
||||
This directory contains the client side (frontend) of Checkmate.
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1,28 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unsafe-function-type": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('i18n-unused').RunOptions} */
|
||||
module.exports = {
|
||||
localesPath: "src/locales",
|
||||
srcPath: "src",
|
||||
translationKeyMatcher: /\bt\s*\(\s*['"`](.*?)['"`]/g,
|
||||
};
|
||||
+13
-19
@@ -1,24 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="./checkmate_favicon.svg"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
|
||||
<title>Checkmate</title>
|
||||
</head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="./checkmate_favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>Checkmate</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/env.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5661
-7344
File diff suppressed because it is too large.
Load diff
+56
-68
@@ -1,70 +1,58 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build-dev": "tsc -b && vite build --mode development",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"format-check": "prettier --check ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/lab": "7.0.0",
|
||||
"@mui/material": "7.3.7",
|
||||
"@mui/x-charts": "7.29.1",
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"human-interval": "2.0.1",
|
||||
"i18next": "25.4.2",
|
||||
"joi": "17.13.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"maplibre-gl": "^5.19.0",
|
||||
"mui-color-input": "^7.0.0",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"react-redux": "9.2.0",
|
||||
"react-router": "^6.23.0",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-toastify": "^10.0.5",
|
||||
"recharts": "2.15.2",
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "^2.3.6",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.5.2",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.1"
|
||||
}
|
||||
"name": "checkmate",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"i18n:display": "i18n-unused display-unused > test.log",
|
||||
"dev": "vite --host 127.0.0.1 --mode development",
|
||||
"build": "tsc -b && vite build --mode production",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/material": "7.3.4",
|
||||
"@mui/x-date-pickers": "8.16.0",
|
||||
"@reduxjs/toolkit": "2.9.0",
|
||||
"axios": "1.12.2",
|
||||
"dayjs": "1.11.18",
|
||||
"flag-icons": "7.5.0",
|
||||
"human-interval": "2.0.1",
|
||||
"lucide-react": "0.553.0",
|
||||
"ms": "2.1.3",
|
||||
"pretty-ms": "9.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "16.0.1",
|
||||
"react-redux": "9.2.0",
|
||||
"react-router": "7.9.4",
|
||||
"react-router-dom": "7.9.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "2.15.2",
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "2.3.6",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"zod": "4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
"globals": "^16.4.0",
|
||||
"i18n-unused": "0.19.0",
|
||||
"prettier": "^3.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
type,name,url,interval,port,expectedValue,jsonPath,matchMethod
|
||||
http,HTTP Monitor,https://www.google.com,60000,,,,
|
||||
http,API Monitor,http://reqres.in/api/users/2,120000,,Janet,data.first_name,equal
|
||||
ping,Ping monitor,127.0.0.1,60000,,,,
|
||||
docker,Docker monitor,bbdd20b4e3757d63e070bc16e04d3f770c4a8f6e62e813de380f23b141e2ca01,60000,,,,
|
||||
port,Port monitor,127.0.0.1,60000,5000,,,
|
||||
|
@@ -1 +0,0 @@
|
||||
type,name,url,interval,port,expectedValue,jsonPath,matchMethod
|
||||
|
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45" width="45" height="45">
|
||||
<title>Checkmate</title>
|
||||
<desc>King silhouette adapted from Cburnett's chess set (Wikimedia Commons, CC BY-SA 3.0).</desc>
|
||||
<g fill="none" stroke="#13715B" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path stroke-linejoin="miter" d="M22.5 11.63V6M20 8h5"/>
|
||||
<path fill="#fff" stroke-linecap="butt" stroke-linejoin="miter" d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5"/>
|
||||
<path fill="#fff" d="M12.5 37c5.5 3.5 14.5 3.5 20 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-2.5-7.5-12-10.5-16-4-3 6 6 10.5 6 10.5v7"/>
|
||||
<path d="M12.5 30c5.5-3 14.5-3 20 0m-20 3.5c5.5-3 14.5-3 20 0m-20 3.5c5.5-3 14.5-3 20 0"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 819 B |
@@ -0,0 +1,7 @@
|
||||
window.__ENV = {
|
||||
GLOBAL_REFRESH: "60000",
|
||||
APP_HOST: "http://localhost:5173",
|
||||
APP_API_BASE_URL: "http://localhost:52345/api/v1",
|
||||
APP_DEPLOYMENT_MODE: "self_hosted",
|
||||
// APP_DEPLOYMENT_MODE: "saas",
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"labels": ["dependencies"]
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Locale Migration Script
|
||||
*
|
||||
* Restructures all non-English locale files to match en.json's nested key structure.
|
||||
* Recovers translations via safe key-matching strategies, uses English placeholders
|
||||
* for unmatched keys.
|
||||
*
|
||||
* Usage: npx tsx client/scripts/migrate-locales.ts
|
||||
*
|
||||
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
|
||||
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
|
||||
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Flatten a nested object into { "a.b.c": value } pairs */
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullPath = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
|
||||
} else {
|
||||
result[fullPath] = String(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Set a value at a dot-separated path in a nested object */
|
||||
function setNestedValue(
|
||||
obj: Record<string, unknown>,
|
||||
keyPath: string,
|
||||
value: string
|
||||
): void {
|
||||
const parts = keyPath.split(".");
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
// ── Build mapping indexes from en.json ───────────────────────────────────────
|
||||
|
||||
function buildIndexes(enFlat: Record<string, string>) {
|
||||
const enKeys = Object.keys(enFlat);
|
||||
|
||||
// last segment -> en key paths
|
||||
const lastSegIndex: Record<string, string[]> = {};
|
||||
// last 2 segments -> en key paths
|
||||
const suffixIndex: Record<string, string[]> = {};
|
||||
|
||||
for (const keyPath of enKeys) {
|
||||
const parts = keyPath.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
|
||||
lastSegIndex[lastSeg].push(keyPath);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const suffix = parts.slice(-2).join(".").toLowerCase();
|
||||
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
|
||||
suffixIndex[suffix].push(keyPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { lastSegIndex, suffixIndex };
|
||||
}
|
||||
|
||||
// ── Mapping logic ────────────────────────────────────────────────────────────
|
||||
|
||||
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
|
||||
|
||||
interface KeyMatch {
|
||||
enKey: string;
|
||||
value: string;
|
||||
strategy: MatchStrategy;
|
||||
}
|
||||
|
||||
function buildMigratedLocale(
|
||||
enFlat: Record<string, string>,
|
||||
langFlat: Record<string, string>,
|
||||
indexes: ReturnType<typeof buildIndexes>
|
||||
): { matches: KeyMatch[]; result: Record<string, unknown> } {
|
||||
const { lastSegIndex, suffixIndex } = indexes;
|
||||
const matches: KeyMatch[] = [];
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
|
||||
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
|
||||
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
|
||||
|
||||
for (const [langKey, langValue] of Object.entries(langFlat)) {
|
||||
const parts = langKey.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
|
||||
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const suffix = parts.slice(-2).join(".").toLowerCase();
|
||||
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
|
||||
langBySuffix[suffix].push({ key: langKey, value: langValue });
|
||||
}
|
||||
}
|
||||
|
||||
for (const enKey of Object.keys(enFlat)) {
|
||||
const enValue = enFlat[enKey];
|
||||
const parts = enKey.split(".");
|
||||
const lastSeg = parts[parts.length - 1].toLowerCase();
|
||||
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
|
||||
|
||||
// Strategy 1: Exact path match
|
||||
if (langFlat[enKey] !== undefined) {
|
||||
const value = langFlat[enKey];
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "exact" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
|
||||
if (suffix) {
|
||||
const enCandidates = suffixIndex[suffix];
|
||||
const langCandidates = langBySuffix[suffix];
|
||||
if (
|
||||
enCandidates &&
|
||||
enCandidates.length === 1 &&
|
||||
langCandidates &&
|
||||
langCandidates.length === 1
|
||||
) {
|
||||
const value = langCandidates[0].value;
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "suffix-2" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Unique last-segment match
|
||||
const enCandidates = lastSegIndex[lastSeg];
|
||||
const langCandidates = langByLastSeg[lastSeg];
|
||||
if (
|
||||
enCandidates &&
|
||||
enCandidates.length === 1 &&
|
||||
langCandidates &&
|
||||
langCandidates.length === 1
|
||||
) {
|
||||
const value = langCandidates[0].value;
|
||||
setNestedValue(result, enKey, value);
|
||||
matches.push({ enKey, value, strategy: "unique-name" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No match — use English placeholder
|
||||
setNestedValue(result, enKey, enValue);
|
||||
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
|
||||
}
|
||||
|
||||
return { matches, result };
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
// Load en.json
|
||||
const enPath = path.join(LOCALES_DIR, "en.json");
|
||||
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
|
||||
const enFlat = flattenKeys(en);
|
||||
const enKeyCount = Object.keys(enFlat).length;
|
||||
const indexes = buildIndexes(enFlat);
|
||||
|
||||
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
|
||||
|
||||
// Find all non-English locale files
|
||||
const localeFiles = fs
|
||||
.readdirSync(LOCALES_DIR)
|
||||
.filter(
|
||||
(f: string) =>
|
||||
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
|
||||
);
|
||||
|
||||
if (localeFiles.length === 0) {
|
||||
console.log("No locale files to migrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create backup directory
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const report: Record<
|
||||
string,
|
||||
{
|
||||
total: number;
|
||||
exact: number;
|
||||
suffix: number;
|
||||
uniqueName: number;
|
||||
placeholder: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const lang = file.replace(".json", "");
|
||||
const filePath = path.join(LOCALES_DIR, file);
|
||||
|
||||
// Backup original
|
||||
const backupPath = path.join(BACKUP_DIR, file);
|
||||
fs.copyFileSync(filePath, backupPath);
|
||||
|
||||
// Load and flatten
|
||||
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
const langFlat = flattenKeys(langData);
|
||||
|
||||
// Migrate
|
||||
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
|
||||
|
||||
// Write migrated file
|
||||
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
|
||||
|
||||
// Compute stats
|
||||
const stats = {
|
||||
total: enKeyCount,
|
||||
exact: matches.filter((m) => m.strategy === "exact").length,
|
||||
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
|
||||
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
|
||||
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
|
||||
};
|
||||
report[lang] = stats;
|
||||
|
||||
const translated = stats.exact + stats.suffix + stats.uniqueName;
|
||||
const pct = ((translated / enKeyCount) * 100).toFixed(1);
|
||||
console.log(
|
||||
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
|
||||
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
|
||||
`placeholder: ${stats.placeholder}`
|
||||
);
|
||||
}
|
||||
|
||||
// Write report
|
||||
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
|
||||
|
||||
const totalLangs = localeFiles.length;
|
||||
console.log(
|
||||
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
|
||||
);
|
||||
console.log(`Report written to ${REPORT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
+30
-68
@@ -1,75 +1,37 @@
|
||||
import { type CSSProperties } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import AppRoutes from "@/routes/Router";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { lightTheme, darkTheme } from "@/theme/theme";
|
||||
import { I18nLoader } from "@/components/i18nLoader";
|
||||
import { useAppSelector } from "./hooks/AppHooks";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthVerifier } from "@/components/auth-verifier";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import { CssBaseline, GlobalStyles } from "@mui/material";
|
||||
import { SWRConfig } from "swr";
|
||||
import { Routes } from "./Routes";
|
||||
import AppLayout from "@/Components/layout/AppLayout";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
|
||||
|
||||
const swrConfig = {
|
||||
dedupingInterval: 5000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
shouldRetryOnError: false,
|
||||
errorRetryCount: 2,
|
||||
errorRetryInterval: 1000,
|
||||
focusThrottleInterval: 5000,
|
||||
};
|
||||
|
||||
function App() {
|
||||
const mode = useSelector((state: RootState) => state.ui.mode);
|
||||
const theme = mode === "light" ? lightTheme : darkTheme;
|
||||
const mode = useAppSelector((state) => state.ui.mode);
|
||||
|
||||
return (
|
||||
<SWRConfig value={swrConfig}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
".Toastify__progress-bar": {
|
||||
height: 2,
|
||||
bottom: 0,
|
||||
top: "auto",
|
||||
},
|
||||
".Toastify__progress-bar--bg": {
|
||||
display: "none",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
<ToastContainer
|
||||
newestOnTop={true}
|
||||
theme={mode}
|
||||
icon={false}
|
||||
style={
|
||||
{
|
||||
"--toastify-color-progress-light": theme.palette.primary.main,
|
||||
"--toastify-color-progress-dark": theme.palette.primary.main,
|
||||
"--toastify-toast-min-height": "40px",
|
||||
"--toastify-toast-padding": "8px 12px",
|
||||
"--toastify-toast-width": "auto",
|
||||
} as CSSProperties
|
||||
}
|
||||
toastStyle={{
|
||||
minHeight: 40,
|
||||
padding: "8px 12px",
|
||||
width: "auto",
|
||||
maxWidth: "min(560px, calc(100vw - 32px))",
|
||||
}}
|
||||
progressStyle={{ height: 2 }}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
return (
|
||||
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
|
||||
<CssBaseline />
|
||||
<I18nLoader />
|
||||
<BrowserRouter>
|
||||
<AuthVerifier>
|
||||
<AppRoutes />
|
||||
</AuthVerifier>
|
||||
</BrowserRouter>
|
||||
<ToastContainer
|
||||
newestOnTop={true}
|
||||
theme={mode}
|
||||
style={
|
||||
{
|
||||
"--toastify-color-progress-light": "#7C8BA1",
|
||||
"--toastify-color-progress-dark": "#7C8BA1",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, MenuItem, Select, Stack } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setLanguage } from "../Features/UI/uiSlice";
|
||||
|
||||
const langMap = {
|
||||
cs: "cz",
|
||||
ja: "jp",
|
||||
uk: "ua",
|
||||
vi: "vn",
|
||||
};
|
||||
|
||||
const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { language = "en" } = useSelector((state) => state.ui);
|
||||
const dispatch = useDispatch();
|
||||
const handleChange = (event) => {
|
||||
const newLang = event.target.value;
|
||||
dispatch(setLanguage(newLang));
|
||||
};
|
||||
|
||||
const languages = Object.keys(i18n.options.resources || {});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={language}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 80,
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
"& .MuiSelect-icon": {
|
||||
alignSelf: "center",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{languages.map((lang) => {
|
||||
let parsedLang = lang === "en" ? "gb" : lang;
|
||||
|
||||
if (parsedLang.includes("-")) {
|
||||
parsedLang = parsedLang.split("-")[1].toLowerCase();
|
||||
}
|
||||
|
||||
parsedLang = langMap[parsedLang] || parsedLang;
|
||||
|
||||
const flag = parsedLang ? `fi fi-${parsedLang}` : null;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={lang}
|
||||
value={lang}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={theme.spacing(2)}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{flag && <span className={flag} />}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ textTransform: "uppercase" }}
|
||||
>
|
||||
{lang}
|
||||
</Box>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,56 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: number | string;
|
||||
label: React.ReactNode;
|
||||
action: Function;
|
||||
closeMenu?: boolean;
|
||||
};
|
||||
|
||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton onClick={handleClick}>
|
||||
<Settings
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
|
||||
e.stopPropagation();
|
||||
if (item.closeMenu) handleClose(e);
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { getResponseColor } from "@/Utils/DataUtils";
|
||||
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
|
||||
interface PlaceholderCheck {
|
||||
status: "placeholder";
|
||||
responseTime: 0;
|
||||
createdAt: "";
|
||||
}
|
||||
|
||||
interface HeatmapResponseTimeProps {
|
||||
checks: CheckSnapshot[];
|
||||
gap?: ResponsiveStyleValue<number | string>;
|
||||
availabilityCellSx?: SxProps;
|
||||
responseCellSx?: SxProps;
|
||||
}
|
||||
|
||||
export const HeatmapResponseTime = ({
|
||||
checks,
|
||||
gap,
|
||||
availabilityCellSx,
|
||||
responseCellSx,
|
||||
}: HeatmapResponseTimeProps) => {
|
||||
const theme = useTheme();
|
||||
if (!gap) {
|
||||
gap = theme.spacing(0.5);
|
||||
}
|
||||
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
const latestChecks = checks.slice(-25);
|
||||
|
||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
||||
if (latestChecks.length !== 25) {
|
||||
const placeholders = Array(25 - latestChecks.length).fill({
|
||||
status: "placeholder" as const,
|
||||
});
|
||||
data = [...latestChecks, ...placeholders];
|
||||
} else {
|
||||
data = latestChecks;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gap,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{data.map((check, index) => {
|
||||
const statusBg =
|
||||
check.status === "placeholder"
|
||||
? theme.palette.action.hover
|
||||
: check.status === true
|
||||
? theme.palette.success.light
|
||||
: theme.palette.error.light;
|
||||
const respBg =
|
||||
check.status === "placeholder"
|
||||
? theme.palette.action.hover
|
||||
: getResponseColor(check.responseTime || 0, {
|
||||
start: theme.palette.success.main,
|
||||
mid: theme.palette.warning.main,
|
||||
end: theme.palette.error.main,
|
||||
});
|
||||
const statusBorder =
|
||||
check.status === "placeholder"
|
||||
? theme.palette.divider
|
||||
: check.status === true
|
||||
? theme.palette.success.main
|
||||
: theme.palette.error.main;
|
||||
|
||||
return (
|
||||
<HeatmapResponseTimeTooltip
|
||||
key={`${check}-${index}`}
|
||||
check={check}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "10",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
bgcolor: statusBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...availabilityCellSx,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
borderRadius: theme.spacing(0.5),
|
||||
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
bgcolor: respBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...responseCellSx,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</HeatmapResponseTimeTooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type HeatmapCheck =
|
||||
| CheckSnapshot
|
||||
| { status: "placeholder"; responseTime: 0; createdAt: "" };
|
||||
|
||||
export const HeatmapResponseTimeTooltip = ({
|
||||
children,
|
||||
check,
|
||||
}: {
|
||||
children: React.ReactElement;
|
||||
check: HeatmapCheck;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
|
||||
const getColor = (status: boolean) => {
|
||||
if (status === true) return theme.palette.success.light;
|
||||
if (status === false) return theme.palette.error.light;
|
||||
};
|
||||
|
||||
if (check.status === "placeholder") {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
backgroundColor: "transparent",
|
||||
color: "inherit",
|
||||
boxShadow: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<Stack
|
||||
sx={{
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
p: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
<Typography>
|
||||
{t("common.labels.responseTime")}:{" "}
|
||||
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
||||
</Typography>
|
||||
<Typography textTransform={"capitalize"}>
|
||||
Status:{" "}
|
||||
<span style={{ color: getColor(check?.status) }}>
|
||||
{check?.status === true
|
||||
? t("pages.common.monitors.status.up")
|
||||
: t("pages.common.monitors.status.down")}
|
||||
</span>
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,155 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HistogramResponseTimeProps {
|
||||
checks: CheckSnapshot[];
|
||||
height?: ResponsiveStyleValue<number | string>;
|
||||
gap?: ResponsiveStyleValue<number | string>;
|
||||
showStats?: boolean;
|
||||
statsPosition?: "left" | "right";
|
||||
}
|
||||
|
||||
interface ResponseTimeStats {
|
||||
max: number | string;
|
||||
avg: number | string;
|
||||
}
|
||||
|
||||
const DEFAULT_HEIGHT = 50;
|
||||
|
||||
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
||||
if (!Array.isArray(checks) || checks.length === 0) {
|
||||
return { max: "-", avg: "-" };
|
||||
}
|
||||
|
||||
const validChecks = checks.filter((check) => check.originalResponseTime != null);
|
||||
if (validChecks.length === 0) {
|
||||
return { max: "-", avg: "-" };
|
||||
}
|
||||
|
||||
const responseTimes = validChecks.map((check) => check.originalResponseTime);
|
||||
const max = Math.max(...responseTimes);
|
||||
const avg = Math.round(
|
||||
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
||||
);
|
||||
|
||||
return { max, avg };
|
||||
};
|
||||
|
||||
export const HistogramResponseTime = ({
|
||||
checks,
|
||||
height = DEFAULT_HEIGHT,
|
||||
gap,
|
||||
showStats = true,
|
||||
statsPosition = "right",
|
||||
}: HistogramResponseTimeProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
||||
|
||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||
|
||||
const data =
|
||||
checks.length !== 25
|
||||
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
|
||||
: checks;
|
||||
|
||||
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"}
|
||||
minWidth={70}
|
||||
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
||||
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{t("common.charts.histogram.avg", { value: stats.avg })}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{t("common.charts.histogram.max", { value: stats.max })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={statsPosition === "left" ? "row-reverse" : "row"}
|
||||
alignItems="center"
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gridGap,
|
||||
alignItems: "end",
|
||||
height: chartHeight,
|
||||
}}
|
||||
>
|
||||
{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 barColor =
|
||||
check.status === true
|
||||
? theme.palette.success.light
|
||||
: theme.palette.error.light;
|
||||
const bar = (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.spacing(1),
|
||||
bgcolor: theme.palette.action.hover,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: isPlaceholder ? 0 : heightPct,
|
||||
bgcolor: barColor,
|
||||
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isPlaceholder ? (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
{bar}
|
||||
</Box>
|
||||
) : (
|
||||
<HeatmapResponseTimeTooltip
|
||||
key={index}
|
||||
check={check}
|
||||
>
|
||||
{bar}
|
||||
</HeatmapResponseTimeTooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
{statsContent}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ButtonInput } from "../../inputs/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HeaderCreateProps {
|
||||
label?: string;
|
||||
path: string;
|
||||
isLoading?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const HeaderCreate = ({
|
||||
label,
|
||||
path,
|
||||
isLoading = false,
|
||||
isAdmin = false,
|
||||
}: HeaderCreateProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
width="100%"
|
||||
>
|
||||
<ButtonInput
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleClick}
|
||||
loading={isLoading}
|
||||
>
|
||||
{label || t("common.buttons.create")}
|
||||
</ButtonInput>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { ToggleButtonGroup, ToggleButton } from "@/Components/inputs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaQuery } from "@mui/material";
|
||||
|
||||
interface MonitorTimeFrameHeaderProps {
|
||||
isLoading?: boolean;
|
||||
hasDateRange?: boolean;
|
||||
dateRange: string;
|
||||
setDateRange: (dateRange: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderTimeRange = ({
|
||||
isLoading = false,
|
||||
hasDateRange = true,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
}: MonitorTimeFrameHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const handleChange = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null
|
||||
) => {
|
||||
if (newValue !== null) {
|
||||
setDateRange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
let timeFramePicker = null;
|
||||
|
||||
if (hasDateRange) {
|
||||
timeFramePicker = (
|
||||
<ToggleButtonGroup
|
||||
orientation={isSmallScreen ? "vertical" : "horizontal"}
|
||||
value={dateRange}
|
||||
exclusive
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
fullWidth={isSmallScreen}
|
||||
>
|
||||
<ToggleButton value="recent">
|
||||
{t("components.headerTimeRange.labels.recent")}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="day">
|
||||
{t("components.headerTimeRange.labels.day")}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="week">
|
||||
{t("components.headerTimeRange.labels.week")}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="month">
|
||||
{t("components.headerTimeRange.labels.month")}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Box
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
{isLoading && <CircularProgress size={16} />}
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
{t(`components.headerTimeRange.description.${dateRange}`)}
|
||||
</Typography>
|
||||
{timeFramePicker}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./charts/HistogramResponseTime";
|
||||
export * from "./charts/HeatmapResponseTime";
|
||||
export * from "./charts/HeatmapResponseTimeTooltip";
|
||||
export * from "./controls/HeaderCreate";
|
||||
export * from "./controls/HeaderTimeRange";
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Avatar as MuiAvatar } from "@mui/material";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string;
|
||||
small?: boolean;
|
||||
sx?: object;
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => {
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const theme = useTheme();
|
||||
if (!user) return null;
|
||||
|
||||
const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 };
|
||||
|
||||
const [image, setImage] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (user.avatarImage) {
|
||||
setImage(`data:image/png;base64,${user.avatarImage}`);
|
||||
}
|
||||
}, [user?.avatarImage]);
|
||||
|
||||
return (
|
||||
<MuiAvatar
|
||||
onClick={onClick}
|
||||
alt={`${user?.firstName} ${user?.lastName}`}
|
||||
src={src ? src : user?.avatarImage ? image : undefined}
|
||||
sx={{
|
||||
color: theme.palette.primary.contrastText,
|
||||
fontSize: small ? "16px" : "22px",
|
||||
fontWeight: 400,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
display: "inline-flex",
|
||||
|
||||
...style,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{user.firstName?.charAt(0)}
|
||||
{user.lastName?.charAt(0) || ""}
|
||||
</MuiAvatar>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import Box, { type BoxProps } from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { BaseBox } from ".";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type BaseChartProps = React.PropsWithChildren<{
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||
}>;
|
||||
|
||||
export const BaseChart = ({
|
||||
children,
|
||||
title,
|
||||
width = "100%",
|
||||
maxWidth = "100%",
|
||||
padding,
|
||||
}: BaseChartProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
width: width,
|
||||
maxWidth: maxWidth,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
flex={1}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Box flex={1}>{children}</Box>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -1,423 +0,0 @@
|
||||
import Logo from "@/assets/icons/checkmate-icon.svg?react";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Link from "@mui/material/Link";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import {
|
||||
ErrorFallback,
|
||||
EmptyFallback,
|
||||
EmptyMonitorFallback,
|
||||
} from "@/Components/design-elements/Fallback";
|
||||
import { EmptyState } from "@/Components/design-elements/EmptyState";
|
||||
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
|
||||
import { PageHeader } from "@/Components/design-elements/PageHeader";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { LanguageSelector, SwitchTheme } from "@/Components/inputs";
|
||||
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
export const PageSpeedKeyPriorityFallback = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const alertBg = alpha(theme.palette.warning.main, 0.08);
|
||||
const alertBorder = alpha(theme.palette.warning.main, 0.45);
|
||||
const alertIcon = theme.palette.warning.main;
|
||||
return (
|
||||
<EmptyState
|
||||
fullscreen
|
||||
title={t("pages.pageSpeed.fallback.title")}
|
||||
description={t("pages.pageSpeed.fallback.description")}
|
||||
alert={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
sx={{
|
||||
width: "100%",
|
||||
p: theme.spacing(LAYOUT.MD),
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${alertBorder}`,
|
||||
backgroundColor: alertBg,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: alertIcon,
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
mt: "2px",
|
||||
}}
|
||||
>
|
||||
⚠
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
sx={{
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface BasePageProps extends StackProps {
|
||||
loading?: boolean;
|
||||
error?: boolean;
|
||||
children: React.ReactNode;
|
||||
breadcrumbOverride?: string[];
|
||||
headerKey?: string;
|
||||
backTo?: string;
|
||||
backLabel?: string;
|
||||
}
|
||||
|
||||
export const BasePage = ({
|
||||
loading,
|
||||
error,
|
||||
children,
|
||||
breadcrumbOverride,
|
||||
headerKey,
|
||||
backTo,
|
||||
backLabel,
|
||||
...props
|
||||
}: BasePageProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
<CircularProgress
|
||||
color="primary"
|
||||
size={28}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorFallback
|
||||
title="Something went wrong..."
|
||||
subtitle="Please try again later"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={theme.spacing(LAYOUT.LG)}
|
||||
{...props}
|
||||
>
|
||||
{headerKey ? (
|
||||
<PageHeader
|
||||
title={t(`pages.${headerKey}.header.title`)}
|
||||
description={t(`pages.${headerKey}.header.description`)}
|
||||
backTo={backTo}
|
||||
backLabel={backLabel}
|
||||
/>
|
||||
) : (
|
||||
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface BasePageWithStatesProps extends StackProps {
|
||||
loading: boolean;
|
||||
error: any;
|
||||
totalCount: number;
|
||||
description?: string;
|
||||
page: string;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
children: React.ReactNode;
|
||||
headerKey?: string;
|
||||
}
|
||||
|
||||
export const BasePageWithStates = ({
|
||||
loading,
|
||||
error,
|
||||
totalCount,
|
||||
page,
|
||||
description,
|
||||
actionButtonText,
|
||||
actionLink,
|
||||
children,
|
||||
headerKey,
|
||||
...props
|
||||
}: BasePageWithStatesProps) => {
|
||||
const showLoading = loading && totalCount === 0;
|
||||
|
||||
if (!loading && totalCount === 0) {
|
||||
return (
|
||||
<EmptyFallback
|
||||
description={description}
|
||||
title={page}
|
||||
actionButtonText={actionButtonText}
|
||||
actionLink={actionLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
loading={showLoading}
|
||||
error={error}
|
||||
headerKey={headerKey}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
interface MonitorBasePageWithStatesProps extends StackProps {
|
||||
loading: boolean;
|
||||
error: any;
|
||||
totalCount: number;
|
||||
page: string;
|
||||
actionLink?: string;
|
||||
children: React.ReactNode;
|
||||
priorityFallback?: React.ReactNode;
|
||||
headerKey?: string;
|
||||
}
|
||||
|
||||
export const MonitorBasePageWithStates = ({
|
||||
loading,
|
||||
error,
|
||||
totalCount,
|
||||
page,
|
||||
actionLink,
|
||||
children,
|
||||
priorityFallback,
|
||||
headerKey,
|
||||
...props
|
||||
}: MonitorBasePageWithStatesProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showLoading = loading && totalCount === 0;
|
||||
|
||||
if (priorityFallback) {
|
||||
return <>{priorityFallback}</>;
|
||||
}
|
||||
|
||||
if (!loading && totalCount === 0) {
|
||||
return (
|
||||
<EmptyMonitorFallback
|
||||
page={page}
|
||||
title={t(`pages.${page}.fallback.title`)}
|
||||
description={t(`pages.${page}.fallback.description`)}
|
||||
actionButtonText={t(`pages.${page}.fallback.actionButton`)}
|
||||
actionLink={actionLink || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
loading={showLoading}
|
||||
error={error}
|
||||
headerKey={headerKey}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
interface BaseAuthPageProps extends React.PropsWithChildren, StackProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export const BaseAuthPage = ({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
component,
|
||||
onSubmit,
|
||||
}: BaseAuthPageProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
minHeight="100vh"
|
||||
width="100%"
|
||||
position="relative"
|
||||
sx={{ backgroundColor: theme.palette.background.default }}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="relative"
|
||||
px={theme.spacing(LAYOUT.MD)}
|
||||
py={{ xs: theme.spacing(20), md: theme.spacing(12) }}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={theme.spacing(2)}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: theme.spacing(LAYOUT.MD),
|
||||
right: theme.spacing(LAYOUT.MD),
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
<LanguageSelector />
|
||||
<SwitchTheme />
|
||||
</Stack>
|
||||
<Box
|
||||
component={component as React.ElementType}
|
||||
onSubmit={onSubmit}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(LAYOUT.MD),
|
||||
width: "100%",
|
||||
maxWidth: 360,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mb={theme.spacing(2)}
|
||||
>
|
||||
<Box width={28}>
|
||||
<Logo width="100%" />
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 500, letterSpacing: "-0.01em", fontSize: 16 }}>
|
||||
{t("common.appName")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 26,
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.02em",
|
||||
color: theme.palette.text.primary,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack
|
||||
flex={1}
|
||||
justifyContent="space-between"
|
||||
display={{ xs: "none", md: "flex" }}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #0c4d3d 0%, #155a48 60%, #0f5d4a 100%)",
|
||||
color: "#fff",
|
||||
p: theme.spacing(28),
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, rgba(255,255,255,0.04) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(255,255,255,0.04) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.04) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.04) 75%)
|
||||
`,
|
||||
backgroundSize: "80px 80px",
|
||||
backgroundPosition: "0 0, 0 40px, 40px -40px, -40px 0px",
|
||||
maskImage: "linear-gradient(135deg, transparent 0%, black 70%)",
|
||||
WebkitMaskImage: "linear-gradient(135deg, transparent 0%, black 70%)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
<Stack sx={{ position: "relative", zIndex: 1, gap: theme.spacing(4) }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.12em",
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
}}
|
||||
>
|
||||
{t("pages.auth.brandPanel.eyebrow")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 44,
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: "-0.02em",
|
||||
maxWidth: 460,
|
||||
}}
|
||||
>
|
||||
{t("pages.auth.brandPanel.tagline")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 17,
|
||||
lineHeight: 1.5,
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
maxWidth: 460,
|
||||
}}
|
||||
>
|
||||
{t("pages.auth.brandPanel.description")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
sx={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
v{__APP_VERSION__}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import MuiBreadcrumbs from "@mui/material/Breadcrumbs";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const isId = (segment: string): boolean => {
|
||||
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
|
||||
};
|
||||
|
||||
const actionSegments = ["create", "configure"];
|
||||
|
||||
const BreadcrumbWrapper = ({ children }: { children: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MuiBreadcrumbs
|
||||
separator={
|
||||
<ChevronRight
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
marginBottom: theme.spacing(6),
|
||||
"& .MuiBreadcrumbs-separator": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MuiBreadcrumbs>
|
||||
);
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({
|
||||
breadcrumbOverride,
|
||||
}: {
|
||||
breadcrumbOverride?: string[] | undefined;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
// If override is an empty array, hide entirely
|
||||
if (breadcrumbOverride !== undefined && breadcrumbOverride.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If override has items, render them directly
|
||||
if (breadcrumbOverride !== undefined && breadcrumbOverride.length > 0) {
|
||||
return (
|
||||
<BreadcrumbWrapper>
|
||||
{breadcrumbOverride.map((item, index) => {
|
||||
const isLast = index === breadcrumbOverride.length - 1;
|
||||
return (
|
||||
<Typography
|
||||
key={index}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: isLast ? 600 : 400,
|
||||
color: isLast ? theme.palette.primary.main : theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Default behavior: use location pathname
|
||||
const segments = location.pathname.split("/").filter((x) => x);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create"
|
||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||
|
||||
const secondSegment = segments[1];
|
||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||
const hasSubPage = isActionPage || isDetailsPage;
|
||||
|
||||
const getSubPageLabel = (): string => {
|
||||
if (isActionPage) {
|
||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||
}
|
||||
return t("common.breadcrumbs.details");
|
||||
};
|
||||
|
||||
return (
|
||||
<BreadcrumbWrapper>
|
||||
{hasSubPage ? (
|
||||
<Link
|
||||
to={`/${basePage}`}
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
color: theme.palette.text.secondary,
|
||||
"&:hover": {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
||||
</Typography>
|
||||
</Link>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: theme.palette.primary.main,
|
||||
}}
|
||||
>
|
||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
||||
</Typography>
|
||||
)}
|
||||
{hasSubPage && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: theme.palette.primary.main,
|
||||
}}
|
||||
>
|
||||
{getSubPageLabel()}
|
||||
</Typography>
|
||||
)}
|
||||
</BreadcrumbWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
export const BulletPointCheck = ({
|
||||
text,
|
||||
variant = "info",
|
||||
}: {
|
||||
text: string;
|
||||
noHighlightText?: string;
|
||||
variant?: "success" | "error" | "info";
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const colors: Record<string, string | undefined> = {
|
||||
success: theme.palette.success.main,
|
||||
error: theme.palette.error.main,
|
||||
info: theme.palette.text.secondary,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Check
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
|
||||
fontWeight={450}
|
||||
sx={{
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { EmptyStateIllustration } from "./EmptyStateIllustration";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actionText?: string;
|
||||
actionTo?: string;
|
||||
onAction?: () => void;
|
||||
secondaryText?: string;
|
||||
secondaryHref?: string;
|
||||
compact?: boolean;
|
||||
fullscreen?: boolean;
|
||||
alert?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const EmptyState = ({
|
||||
title,
|
||||
description,
|
||||
actionText,
|
||||
actionTo,
|
||||
onAction,
|
||||
secondaryText,
|
||||
secondaryHref,
|
||||
compact = false,
|
||||
fullscreen = false,
|
||||
alert,
|
||||
children,
|
||||
}: EmptyStateProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
sx={{ py: theme.spacing(LAYOUT.MD), px: theme.spacing(LAYOUT.SM) }}
|
||||
>
|
||||
<EmptyStateIllustration
|
||||
width={80}
|
||||
height={60}
|
||||
/>
|
||||
<Stack sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{
|
||||
...(fullscreen && { minHeight: "65vh" }),
|
||||
py: theme.spacing(LAYOUT.XXL),
|
||||
px: theme.spacing(LAYOUT.MD),
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<EmptyStateIllustration />
|
||||
<Typography
|
||||
sx={{
|
||||
mt: theme.spacing(LAYOUT.MD),
|
||||
mb: theme.spacing(LAYOUT.XS),
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
maxWidth: 360,
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
mb: theme.spacing(LAYOUT.LG),
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{alert && (
|
||||
<Stack sx={{ width: "100%", maxWidth: 520, mb: theme.spacing(LAYOUT.MD) }}>
|
||||
{alert}
|
||||
</Stack>
|
||||
)}
|
||||
{(actionText || secondaryText) && (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
{actionText && (actionTo || onAction) && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
{...(actionTo
|
||||
? { component: RouterLink, to: actionTo }
|
||||
: { onClick: onAction })}
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryText && secondaryHref && (
|
||||
<Typography
|
||||
component="a"
|
||||
href={secondaryHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: theme.palette.primary.main,
|
||||
textDecoration: "none",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{secondaryText} →
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface EmptyStateIllustrationProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const PALETTE = {
|
||||
light: {
|
||||
grid: "#CBD5E1",
|
||||
backFill: "#F5F7FA",
|
||||
backStroke: "#D5DBE3",
|
||||
backInner: "#B6C2CF",
|
||||
frontFill: "#EAF5F0",
|
||||
frontStroke: "#7DBFAA",
|
||||
frontInner: "#4DAF94",
|
||||
},
|
||||
dark: {
|
||||
grid: "#3A4452",
|
||||
backFill: "#475569",
|
||||
backStroke: "#64748B",
|
||||
backInner: "#CBD5E1",
|
||||
frontFill: "#0F5D4A",
|
||||
frontStroke: "#4DAF94",
|
||||
frontInner: "#B5E2D2",
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyStateIllustration = ({
|
||||
width = 160,
|
||||
height = 120,
|
||||
}: EmptyStateIllustrationProps) => {
|
||||
const theme = useTheme();
|
||||
const p = theme.palette.mode === "dark" ? PALETTE.dark : PALETTE.light;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 160 120"
|
||||
width={width}
|
||||
height={height}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g
|
||||
stroke={p.grid}
|
||||
strokeWidth="0.75"
|
||||
>
|
||||
<line
|
||||
x1="16"
|
||||
y1="0"
|
||||
x2="16"
|
||||
y2="120"
|
||||
/>
|
||||
<line
|
||||
x1="56"
|
||||
y1="0"
|
||||
x2="56"
|
||||
y2="120"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="104"
|
||||
y1="0"
|
||||
x2="104"
|
||||
y2="120"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="144"
|
||||
y1="0"
|
||||
x2="144"
|
||||
y2="120"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="12"
|
||||
x2="160"
|
||||
y2="12"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="44"
|
||||
x2="160"
|
||||
y2="44"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="76"
|
||||
x2="160"
|
||||
y2="76"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="108"
|
||||
x2="160"
|
||||
y2="108"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect
|
||||
x="38"
|
||||
y="22"
|
||||
width="92"
|
||||
height="42"
|
||||
rx="6"
|
||||
fill={p.backFill}
|
||||
stroke={p.backStroke}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="48"
|
||||
y="32"
|
||||
width="14"
|
||||
height="22"
|
||||
rx="2"
|
||||
fill={p.backInner}
|
||||
opacity="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="68"
|
||||
y="34"
|
||||
width="50"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill={p.backInner}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="68"
|
||||
y="42"
|
||||
width="38"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill={p.backInner}
|
||||
opacity="0.55"
|
||||
/>
|
||||
<rect
|
||||
x="68"
|
||||
y="50"
|
||||
width="44"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill={p.backInner}
|
||||
opacity="0.55"
|
||||
/>
|
||||
<rect
|
||||
x="22"
|
||||
y="50"
|
||||
width="92"
|
||||
height="42"
|
||||
rx="6"
|
||||
fill={p.frontFill}
|
||||
stroke={p.frontStroke}
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x="32"
|
||||
y="60"
|
||||
width="14"
|
||||
height="22"
|
||||
rx="2"
|
||||
fill={p.frontInner}
|
||||
opacity="0.35"
|
||||
/>
|
||||
<rect
|
||||
x="52"
|
||||
y="62"
|
||||
width="50"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill={p.frontInner}
|
||||
opacity="0.55"
|
||||
/>
|
||||
<rect
|
||||
x="52"
|
||||
y="70"
|
||||
width="38"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill={p.frontInner}
|
||||
opacity="0.4"
|
||||
/>
|
||||
<rect
|
||||
x="52"
|
||||
y="78"
|
||||
width="44"
|
||||
height="2.5"
|
||||
rx="1.25"
|
||||
fill={p.frontInner}
|
||||
opacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
|
||||
interface BaseFallbackProps extends StackProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{ width: "100%", py: theme.spacing(LAYOUT.LG) }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorFallback = ({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) => {
|
||||
return (
|
||||
<EmptyState
|
||||
fullscreen
|
||||
title={title}
|
||||
description={subtitle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyFallback = ({
|
||||
title,
|
||||
description,
|
||||
actionButtonText,
|
||||
actionLink,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
}) => {
|
||||
return (
|
||||
<EmptyState
|
||||
fullscreen
|
||||
title={title}
|
||||
description={description}
|
||||
actionText={actionButtonText}
|
||||
actionTo={actionLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyMonitorFallback = ({
|
||||
title,
|
||||
description,
|
||||
actionButtonText,
|
||||
actionLink,
|
||||
}: {
|
||||
page: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
}) => {
|
||||
return (
|
||||
<EmptyState
|
||||
fullscreen
|
||||
title={title}
|
||||
description={description}
|
||||
actionText={actionButtonText}
|
||||
actionTo={actionLink}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
import { BaseChart } from "@/Components/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
|
||||
|
||||
const MINIMUM_VALUE = 0;
|
||||
const MAXIMUM_VALUE = 100;
|
||||
|
||||
export const Gauge = ({
|
||||
isLoading = false,
|
||||
progress = 0,
|
||||
radius = 70,
|
||||
strokeWidth = 15,
|
||||
precision = 1,
|
||||
unit = "%",
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
progress?: number;
|
||||
radius?: number;
|
||||
strokeWidth?: number;
|
||||
precision?: number;
|
||||
unit?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
|
||||
|
||||
// Calculate the length of the stroke for the circle
|
||||
const { circumference, totalSize, strokeLength } = useMemo(
|
||||
() => ({
|
||||
circumference: 2 * Math.PI * radius,
|
||||
totalSize: radius * 2 + strokeWidth * 2,
|
||||
strokeLength: (progress / 100) * (2 * Math.PI * radius),
|
||||
}),
|
||||
[radius, strokeWidth, progress]
|
||||
);
|
||||
|
||||
const [offset, setOffset] = useState(circumference);
|
||||
useEffect(() => {
|
||||
setOffset(circumference);
|
||||
const timer = setTimeout(() => {
|
||||
setOffset(circumference - strokeLength);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [progress, circumference, strokeLength]);
|
||||
|
||||
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
||||
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
display={"inline-block"}
|
||||
position={"relative"}
|
||||
width={radius}
|
||||
height={radius}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
borderRadius={"50%"}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${totalSize} ${totalSize}`}
|
||||
width={radius}
|
||||
height={radius}
|
||||
>
|
||||
<circle
|
||||
stroke={theme.palette.secondary.main}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
cx={totalSize / 2}
|
||||
cy={totalSize / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<circle
|
||||
stroke={fillColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
fill="none"
|
||||
cx={totalSize / 2}
|
||||
cy={totalSize / 2}
|
||||
r={radius}
|
||||
style={{
|
||||
transform: "rotate(-90deg)",
|
||||
transformOrigin: "center",
|
||||
transition: "stroke-dashoffset 1.5s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Typography
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
{`${progressWithinRange.toFixed(precision)}${unit}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const DetailGauge = ({
|
||||
title,
|
||||
progress,
|
||||
upperLabel,
|
||||
upperValue,
|
||||
lowerLabel,
|
||||
lowerValue,
|
||||
maxWidth = 225,
|
||||
}: {
|
||||
title: string;
|
||||
progress: number;
|
||||
upperLabel?: string;
|
||||
upperValue?: string | number;
|
||||
lowerLabel?: string;
|
||||
lowerValue?: string | number;
|
||||
maxWidth?: number;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseChart
|
||||
icon={null}
|
||||
title={title}
|
||||
maxWidth={maxWidth}
|
||||
>
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
mb={theme.spacing(4)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Gauge progress={progress} />
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<Typography>{upperLabel}</Typography>
|
||||
<Typography>{upperValue}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<Typography>{lowerLabel}</Typography>
|
||||
<Typography>{lowerValue}</Typography>
|
||||
</Stack>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface IconProps {
|
||||
icon: LucideIcon;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
stroke?: string;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { PulseDot, Dot } 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";
|
||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xl}
|
||||
fontWeight={500}
|
||||
overflow={"hidden"}
|
||||
textOverflow={"ellipsis"}
|
||||
whiteSpace={"nowrap"}
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
||||
<Typography
|
||||
fontSize={typographyLevels.l}
|
||||
fontWeight={"bolder"}
|
||||
fontFamily={theme.typography.fontFamilyMonospace}
|
||||
overflow={"hidden"}
|
||||
textOverflow={"ellipsis"}
|
||||
whiteSpace={"nowrap"}
|
||||
>
|
||||
{formatUrl(monitor?.url)}
|
||||
</Typography>
|
||||
{!isSmall && (
|
||||
<>
|
||||
<Dot />
|
||||
<Typography>
|
||||
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface OfflineBannerProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [shouldRender, setShouldRender] = useState(visible);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShouldRender(true);
|
||||
requestAnimationFrame(() => setIsAnimating(true));
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
const timer = setTimeout(() => setShouldRender(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: isAnimating ? 0 : "-100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: theme.palette.error.contrastText,
|
||||
px: theme.spacing(8),
|
||||
py: theme.spacing(4),
|
||||
transition: "top 1s ease-in-out",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<WifiOff size={20} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t("components.offlineBanner.serverUnreachable")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const EXTERNAL_LINKS = {
|
||||
support: "https://discord.com/invite/NAb6H3UTjK",
|
||||
docs: "https://checkmate.so/docs",
|
||||
changelog: "https://github.com/bluewave-labs/checkmate/releases",
|
||||
};
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
backTo?: string;
|
||||
backLabel?: string;
|
||||
}
|
||||
|
||||
export const PageHeader = ({
|
||||
title,
|
||||
description,
|
||||
backTo,
|
||||
backLabel,
|
||||
}: PageHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const linkItems: { key: keyof typeof EXTERNAL_LINKS; icon: ReactNode }[] = [
|
||||
{
|
||||
key: "support",
|
||||
icon: (
|
||||
<HelpCircle
|
||||
size={16}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
icon: (
|
||||
<FileText
|
||||
size={16}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "changelog",
|
||||
icon: (
|
||||
<Code
|
||||
size={16}
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
gap={6}
|
||||
sx={{ mb: theme.spacing(8) }}
|
||||
>
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
{backTo && backLabel && (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={backTo}
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
textDecoration: "none",
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 13,
|
||||
mb: theme.spacing(1),
|
||||
"&:hover": { color: theme.palette.text.primary },
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={14}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{backLabel}
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 26,
|
||||
fontWeight: 400,
|
||||
color: theme.palette.text.primary,
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
maxWidth: 640,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 1,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{linkItems.map((item, idx) => (
|
||||
<Tooltip
|
||||
key={item.key}
|
||||
title={t(`components.pageHeader.links.${item.key}`)}
|
||||
>
|
||||
<Box
|
||||
component="a"
|
||||
href={EXTERNAL_LINKS[item.key]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 32,
|
||||
color: theme.palette.text.secondary,
|
||||
borderRight:
|
||||
idx < linkItems.length - 1
|
||||
? `1px solid ${theme.palette.divider}`
|
||||
: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
width?: number | string | { xs?: number | string; md?: number | string };
|
||||
showHalo?: boolean;
|
||||
}
|
||||
|
||||
export const SkeletonCard = ({
|
||||
width = { xs: 130, md: 216 },
|
||||
showHalo = true,
|
||||
}: SkeletonCardProps) => {
|
||||
const theme = useTheme();
|
||||
const pulseAnimation = {
|
||||
"@keyframes pulse": {
|
||||
"0%, 100%": { opacity: 1 },
|
||||
"50%": { opacity: 0.72 },
|
||||
},
|
||||
};
|
||||
|
||||
const floatAnimation = {
|
||||
"@keyframes float": {
|
||||
"0%, 100%": { transform: "rotate(5deg) translate(12px, -20px)" },
|
||||
"50%": { transform: "rotate(5deg) translate(12px, -24px)" },
|
||||
},
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
width: width,
|
||||
height: 57,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background:
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.secondary.dark
|
||||
: theme.palette.background.paper,
|
||||
boxShadow:
|
||||
theme.palette.mode === "dark"
|
||||
? "0 8px 30px rgba(0,0,0,.3), 0 0 0 1px rgba(255,255,255,.05) inset"
|
||||
: "0 8px 30px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05) inset",
|
||||
display: "flex",
|
||||
gap: theme.spacing(5),
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(5),
|
||||
};
|
||||
|
||||
const blockStyle = {
|
||||
width: 55,
|
||||
height: 34,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
animation: "pulse 1.6s ease-in-out infinite",
|
||||
...pulseAnimation,
|
||||
};
|
||||
|
||||
const lineStyle = {
|
||||
height: 7,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
animation: "pulse 1.6s ease-in-out infinite",
|
||||
...pulseAnimation,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position={"relative"}
|
||||
mt={20}
|
||||
mb={20}
|
||||
>
|
||||
{showHalo && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
pointerEvents: "none",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
width: "260px",
|
||||
height: "260px",
|
||||
borderRadius: "999px",
|
||||
background:
|
||||
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "#fff",
|
||||
filter: "blur(60px)",
|
||||
opacity: 0.9,
|
||||
},
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Card stack */}
|
||||
<Box sx={{ position: "relative", zIndex: 1 }}>
|
||||
{/* Front card (now at bottom) */}
|
||||
<Box sx={cardStyle}>
|
||||
<Box sx={blockStyle} />
|
||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
||||
{/* Empty space to maintain card height */}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Back card A */}
|
||||
<Box
|
||||
sx={{
|
||||
...cardStyle,
|
||||
transform: "rotate(-4deg) translate(-12px, -12px)",
|
||||
filter: "blur(.2px)",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Box sx={blockStyle} />
|
||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
||||
<Box sx={{ ...lineStyle, width: "60%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "80%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "40%" }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Back card B */}
|
||||
<Box
|
||||
sx={{
|
||||
...cardStyle,
|
||||
transform: "rotate(5deg) translate(12px, -20px)",
|
||||
filter: "blur(.3px)",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
animation: "float 3s ease-in-out 5",
|
||||
...floatAnimation,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Box sx={blockStyle} />
|
||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
||||
<Box sx={{ ...lineStyle, width: "60%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "80%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "40%" }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
export const SplitBox = ({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
return (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={1}
|
||||
borderColor={theme.palette.divider}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
>
|
||||
<Box
|
||||
padding={theme.spacing(LAYOUT.XXL)}
|
||||
borderRight={isSmall ? 0 : 1}
|
||||
borderBottom={isSmall ? 1 : 0}
|
||||
borderColor={theme.palette.divider}
|
||||
flex={0.7}
|
||||
sx={{
|
||||
background:
|
||||
theme.palette.mode === "dark"
|
||||
? "linear-gradient(135deg, rgba(255, 255, 255, 0.01) 0%, rgba(255, 255, 255, 0.02) 100%)"
|
||||
: "linear-gradient(135deg, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.02) 100%)",
|
||||
}}
|
||||
>
|
||||
{left}
|
||||
</Box>
|
||||
<Box
|
||||
flex={1}
|
||||
padding={theme.spacing(LAYOUT.XXL)}
|
||||
>
|
||||
{right}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigBox = ({
|
||||
title,
|
||||
subtitle,
|
||||
leftContent,
|
||||
rightContent,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
leftContent?: React.ReactNode;
|
||||
rightContent: React.ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SplitBox
|
||||
left={
|
||||
<Stack spacing={theme.spacing(LAYOUT.XS)}>
|
||||
<Typography
|
||||
component="h2"
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography component="p">{subtitle}</Typography>
|
||||
{leftContent}
|
||||
</Stack>
|
||||
}
|
||||
right={rightContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
type GradientBox = React.PropsWithChildren<{
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
}>;
|
||||
|
||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
||||
const paperEnd = paper;
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||
background: bg,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
type StatBoxProps = React.PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
|
||||
export const StatBox = ({
|
||||
title,
|
||||
subtitle,
|
||||
palette,
|
||||
children,
|
||||
sx,
|
||||
tooltip,
|
||||
onClick,
|
||||
}: StatBoxProps) => {
|
||||
const theme = useTheme();
|
||||
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
|
||||
|
||||
return (
|
||||
<GradientBox
|
||||
palette={palette}
|
||||
sx={{
|
||||
...(sx as object),
|
||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
||||
}}
|
||||
>
|
||||
<Stack onClick={onClick}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={palette ? textColor : "text.secondary"}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{tooltip && (
|
||||
<TooltipWithInfo
|
||||
title={tooltip}
|
||||
iconColor={textColor as string}
|
||||
iconSize={14}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography color={textColor}>{subtitle}</Typography>
|
||||
{children}
|
||||
</Stack>
|
||||
</GradientBox>
|
||||
);
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type StatusBoxProps = React.PropsWithChildren<{
|
||||
children: React.ReactNode;
|
||||
sx?: SxProps;
|
||||
}>;
|
||||
|
||||
export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.default,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
padding: theme.spacing(6),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
backgroundImage: `
|
||||
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
"linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBox = ({
|
||||
label,
|
||||
n,
|
||||
color,
|
||||
sx,
|
||||
}: {
|
||||
label: string;
|
||||
n: number;
|
||||
color: string | undefined;
|
||||
sx?: SxProps;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BGBox sx={sx}>
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Typography
|
||||
textTransform="uppercase"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.08em",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h1"
|
||||
color={color}
|
||||
>
|
||||
{n}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</BGBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.up")}
|
||||
n={n}
|
||||
color={theme.palette.success.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DownStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.down")}
|
||||
n={n}
|
||||
color={theme.palette.error.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PausedStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.paused")}
|
||||
n={n}
|
||||
color={theme.palette.warning.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const MaintenanceStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.maintenance")}
|
||||
n={n}
|
||||
color={theme.palette.warning.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const InitializingStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.initializing")}
|
||||
n={n}
|
||||
color={theme.palette.warning.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const BreachedStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.breached")}
|
||||
n={n}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TotalChecksBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.total")}
|
||||
n={n}
|
||||
color={theme.palette.primary.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const DownChecksBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.down")}
|
||||
n={n}
|
||||
color={theme.palette.error.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const UpChecksBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.up")}
|
||||
n={n}
|
||||
color={theme.palette.success.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import type { MonitorStatus } from "@/Types/Monitor";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||
export type ValueType = (typeof ValueTypes)[number];
|
||||
|
||||
export const StatusLabel = ({ status, sx }: { status: MonitorStatus; sx?: SxProps }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const palette = getStatusPalette(status);
|
||||
|
||||
const determineStatus = (status: MonitorStatus): string => {
|
||||
if (status === "up") {
|
||||
return t("pages.common.monitors.status.up");
|
||||
} else if (status === "down") {
|
||||
return t("pages.common.monitors.status.down");
|
||||
} else if (status === "breached") {
|
||||
return t("pages.common.monitors.status.breached");
|
||||
} else if (status === "maintenance") {
|
||||
return t("pages.common.monitors.status.maintenance");
|
||||
} else if (status === "paused") {
|
||||
return t("pages.common.monitors.status.paused");
|
||||
} else if (status === "initializing") {
|
||||
return t("pages.common.monitors.status.initializing");
|
||||
}
|
||||
|
||||
return t("pages.common.monitors.status.initializing");
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing(3, 5),
|
||||
color: theme.palette[palette].main,
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.08)"
|
||||
: "rgba(0, 0, 0, 0.08)",
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={7}
|
||||
height={7}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
<Typography textTransform={"capitalize"}>{determineStatus(status)}</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) => {
|
||||
const theme = useTheme();
|
||||
const palette = getValuePalette(value);
|
||||
const transformedText = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing(3, 5),
|
||||
color: theme.palette[palette].main,
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.08)"
|
||||
: "rgba(0, 0, 0, 0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={7}
|
||||
height={7}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
<Typography>{transformedText}</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -1,569 +0,0 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Grid2 from "@mui/material/Grid";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Ellipsis,
|
||||
} from "lucide-react";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
import TablePagination from "@mui/material/TablePagination";
|
||||
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, Fragment, useEffect } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
export type Header<T> = {
|
||||
id: number | string;
|
||||
content: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||
render: (row: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
|
||||
headers: Header<T>[];
|
||||
data: T[];
|
||||
onRowClick?: (row: T) => void;
|
||||
cardsOnSmallScreens?: boolean;
|
||||
expandableRows?: boolean;
|
||||
renderExpandedContent?: (row: T) => React.ReactNode;
|
||||
emptyViewText?: string;
|
||||
emptyViewPositive?: boolean;
|
||||
getRowSx?: (row: T) => SxProps<Theme>;
|
||||
};
|
||||
|
||||
export function DataTable<
|
||||
T extends {
|
||||
id?: string | number;
|
||||
_id?: string | number;
|
||||
onRowClick?: (row: T) => void;
|
||||
},
|
||||
>({
|
||||
headers,
|
||||
data,
|
||||
onRowClick,
|
||||
cardsOnSmallScreens = true,
|
||||
expandableRows = false,
|
||||
renderExpandedContent,
|
||||
emptyViewText,
|
||||
emptyViewPositive,
|
||||
getRowSx,
|
||||
}: DataTableProps<T>) {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||
const handleExpand = (row: T) => {
|
||||
const key = row.id || row._id || null;
|
||||
setExpanded(expanded === key ? null : key);
|
||||
};
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
if (data.length === 0 || headers.length === 0) {
|
||||
return (
|
||||
<EmptyView
|
||||
text={emptyViewText}
|
||||
positive={emptyViewPositive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
// Return stack of cards for small screens
|
||||
if (isSmall && cardsOnSmallScreens) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(LAYOUT.XS)}>
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
keys.push(key);
|
||||
return (
|
||||
<Stack
|
||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
sx={{
|
||||
borderStyle: "solid",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(LAYOUT.XS),
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
}}
|
||||
key={key}
|
||||
>
|
||||
{headers.map((header) => {
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
key={header.id}
|
||||
>
|
||||
<Grid2
|
||||
size={5}
|
||||
display={"flex"}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<Typography
|
||||
component="div"
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{header.content}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2
|
||||
size={7}
|
||||
display="flex"
|
||||
alignItems={"center"}
|
||||
>
|
||||
{header.render(row)}{" "}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
})}
|
||||
{expandableRows && renderExpandedContent && renderExpandedContent(row) && (
|
||||
<Grid2 size={12}>{renderExpandedContent(row)}</Grid2>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
elevation={0}
|
||||
sx={{ boxShadow: "none", overflowX: "hidden" }}
|
||||
>
|
||||
<Table
|
||||
sx={{
|
||||
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
|
||||
paddingLeft: theme.spacing(LAYOUT.MD),
|
||||
},
|
||||
"& .MuiTableCell-root": {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
"& .MuiTableHead-root .MuiTableRow-root": {
|
||||
height: "28px",
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
"& .MuiTableHead-root .MuiTableCell-root": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
"& :is(th)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||
fontSize: 11,
|
||||
},
|
||||
"& :is(td)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`,
|
||||
fontSize: theme.typography.fontSize,
|
||||
overflowX: "hidden",
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map((header, idx) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={idx === 0 ? "left" : "center"}
|
||||
key={header.id}
|
||||
>
|
||||
{header.content}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
const isExpanded = expanded === key;
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<TableRow
|
||||
sx={{
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
...(getRowSx?.(row) as object),
|
||||
}}
|
||||
onClick={() => {
|
||||
if (expandableRows) handleExpand(row);
|
||||
else if (onRowClick) onRowClick(row);
|
||||
}}
|
||||
>
|
||||
{headers.map((header, index) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={index === 0 ? "left" : "center"}
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||
}
|
||||
>
|
||||
{header.render(row)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
{expandableRows &&
|
||||
renderExpandedContent &&
|
||||
renderExpandedContent(row) && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={headers.length}
|
||||
style={{
|
||||
borderBottom: isExpanded ? undefined : "none",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
in={isExpanded}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
>
|
||||
<Box sx={{ pt: 4, pb: 4 }}>{renderExpandedContent(row)}</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface TablePaginationActionsProps {
|
||||
count: number;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||
}
|
||||
|
||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
const theme = useTheme();
|
||||
const { count, page, rowsPerPage, onPageChange } = props;
|
||||
|
||||
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, 0);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page - 1);
|
||||
};
|
||||
|
||||
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page + 1);
|
||||
};
|
||||
|
||||
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
|
||||
className="table-pagination-actions"
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronsLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="next page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleLastPageButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="last page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronsRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface HasMoreTablePaginationActionsProps {
|
||||
hasMore?: boolean;
|
||||
page: number;
|
||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||
}
|
||||
function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) {
|
||||
const theme = useTheme();
|
||||
const { hasMore, page, onPageChange } = props;
|
||||
|
||||
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, 0);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page - 1);
|
||||
};
|
||||
|
||||
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
|
||||
className="table-pagination-actions"
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronsLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<Tooltip title={hasMore === true ? "More pages available" : "No more pages"}>
|
||||
<IconButton
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
>
|
||||
<Ellipsis
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaginationProps extends TablePaginationOwnProps {
|
||||
component?: React.ElementType;
|
||||
hasMore?: boolean;
|
||||
itemsOnPage?: number;
|
||||
}
|
||||
|
||||
export const Pagination = ({ ...props }: PaginationProps) => {
|
||||
const { hasMore, itemsOnPage, ...rest } = props;
|
||||
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof itemsOnPage === "number" &&
|
||||
itemsOnPage === 0 &&
|
||||
rest.count > 0 &&
|
||||
rest.page > 0 &&
|
||||
rest.onPageChange
|
||||
) {
|
||||
rest.onPageChange(null, rest.page - 1);
|
||||
}
|
||||
}, [itemsOnPage, rest.count, rest.page, rest.onPageChange]);
|
||||
|
||||
const labelDisplayedRows = ({
|
||||
from,
|
||||
to,
|
||||
count,
|
||||
}: {
|
||||
from: number;
|
||||
to: number;
|
||||
count: number;
|
||||
}) => {
|
||||
if (hasMore) {
|
||||
return to === 0 ? "" : `${from}–${to}`;
|
||||
}
|
||||
return `${from}–${to} of ${count}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TablePagination
|
||||
ActionsComponent={(props) =>
|
||||
typeof hasMore === "boolean" ? (
|
||||
<HasMoreTablePaginationActions
|
||||
{...props}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
) : (
|
||||
<TablePaginationActions {...props} />
|
||||
)
|
||||
}
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
labelDisplayedRows={labelDisplayedRows}
|
||||
{...rest}
|
||||
sx={{
|
||||
"& .MuiTablePagination-toolbar": isSmall
|
||||
? {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gridAutoRows: "auto",
|
||||
rowGap: theme.spacing(SPACING.SM),
|
||||
alignItems: "center",
|
||||
justifyItems: "center",
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}
|
||||
: { display: "flex", alignItems: "center" },
|
||||
"& .MuiTablePagination-selectLabel": {
|
||||
gridColumn: "1",
|
||||
gridRow: "1",
|
||||
justifySelf: "center",
|
||||
},
|
||||
"& .MuiTablePagination-select": {
|
||||
gridColumn: "2",
|
||||
gridRow: "1",
|
||||
justifySelf: "center",
|
||||
},
|
||||
"& .MuiTablePagination-displayedRows": isSmall
|
||||
? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" }
|
||||
: {},
|
||||
"& .table-pagination-actions": isSmall
|
||||
? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" }
|
||||
: {},
|
||||
"& .MuiSelect-select": {
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyView = ({ text }: { text?: string; positive?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: 1,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
<EmptyState title={text ?? t("common.table.empty")} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import type { TabsProps } from "@mui/material/Tabs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
interface CustomTabsProps extends TabsProps {}
|
||||
|
||||
export const Tabs = (props: CustomTabsProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MuiTabs
|
||||
sx={{
|
||||
minHeight: 34,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
"& .MuiTabs-indicator": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
height: 2,
|
||||
bottom: 0,
|
||||
},
|
||||
"& .MuiTabs-flexContainer": {
|
||||
gap: theme.spacing(16),
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</MuiTabs>
|
||||
);
|
||||
};
|
||||
|
||||
import MuiTab from "@mui/material/Tab";
|
||||
import type { TabProps } from "@mui/material/Tab";
|
||||
interface CustomTabProps extends TabProps {}
|
||||
|
||||
export const Tab = (props: CustomTabProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MuiTab
|
||||
disableRipple
|
||||
iconPosition="start"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
minHeight: 34,
|
||||
padding: theme.spacing(1, 0),
|
||||
paddingBottom: 0,
|
||||
minWidth: "auto",
|
||||
alignItems: "flex-start",
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-selected": {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
},
|
||||
"&:hover": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
"& .MuiTab-iconWrapper": {
|
||||
marginRight: theme.spacing(2),
|
||||
marginBottom: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
import Link from "@mui/material/Link";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface TextLinkProps extends StackProps {
|
||||
text: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
target?: "_self" | "_blank" | "_parent" | "_top";
|
||||
}
|
||||
|
||||
export const TextLink = ({
|
||||
text,
|
||||
linkText,
|
||||
href,
|
||||
target = "_self",
|
||||
...props
|
||||
}: TextLinkProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
{...props}
|
||||
>
|
||||
<Typography>{text}</Typography>
|
||||
<Link
|
||||
color="primary"
|
||||
to={href}
|
||||
component={RouterLink}
|
||||
target={target}
|
||||
{...(target === "_blank" && { rel: "noopener noreferrer" })}
|
||||
>
|
||||
{linkText}
|
||||
</Link>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import MuiTooltip from "@mui/material/Tooltip";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import type { TooltipProps } from "@mui/material/Tooltip";
|
||||
|
||||
type StyledTooltipProps = TooltipProps;
|
||||
|
||||
export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<MuiTooltip
|
||||
title={props.title}
|
||||
placement={placement}
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[800],
|
||||
color: theme.palette.common.white,
|
||||
fontSize: "13px",
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
boxShadow: theme.shadows[6],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</MuiTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TooltipWithInfoProps extends Omit<StyledTooltipProps, "children"> {
|
||||
iconSize?: number;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export const TooltipWithInfo = ({
|
||||
iconSize = 14,
|
||||
iconColor,
|
||||
...props
|
||||
}: TooltipWithInfoProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const defaultColor = theme.palette.text.secondary;
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
size={iconSize}
|
||||
strokeWidth={1.5}
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
color: iconColor || defaultColor,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "0.7";
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
export * from "./Breadcrumb";
|
||||
export * from "./PageHeader";
|
||||
export * from "./BasePage";
|
||||
export * from "./EmptyState";
|
||||
export * from "./EmptyStateIllustration";
|
||||
export * from "./Fallback";
|
||||
export * from "./SkeletonCard";
|
||||
export * from "./BulletPointCheck";
|
||||
export * from "./StatusBox";
|
||||
export * from "./BaseBox";
|
||||
export * from "./Table";
|
||||
export { DataTable as Table } from "./Table";
|
||||
export * from "./StatusLabel";
|
||||
export * from "./PulseDot";
|
||||
export * from "./Dot";
|
||||
export * from "./MonitorStatus";
|
||||
export { default as Icon } from "./Icon";
|
||||
export * from "./Tooltip";
|
||||
export * from "./StatBox";
|
||||
export * from "./BaseChart";
|
||||
export * from "./Gauge";
|
||||
export * from "./Tabs";
|
||||
export * from "./SplitBox";
|
||||
export * from "./TextLink";
|
||||
export * from "./OfflineBanner";
|
||||
export * from "./Avatar";
|
||||
@@ -1,17 +0,0 @@
|
||||
import i18n from "@/Utils/i18n.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useEffect } from "react";
|
||||
import type { RootState } from "@/store";
|
||||
const I18nLoader = () => {
|
||||
const language = useSelector((state: RootState) => state.ui.language ?? "en");
|
||||
|
||||
useEffect(() => {
|
||||
if (language && i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default I18nLoader;
|
||||
@@ -1,88 +0,0 @@
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
||||
import { TextField, Checkbox } from "@/Components/inputs";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type AutoCompleteInputProps = Omit<
|
||||
AutocompleteProps<any, boolean, boolean, boolean>,
|
||||
"renderInput"
|
||||
> & {
|
||||
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export const AutoCompleteInput = ({
|
||||
fieldLabel,
|
||||
required,
|
||||
renderInput,
|
||||
...props
|
||||
}: AutoCompleteInputProps) => {
|
||||
const theme = useTheme();
|
||||
const multiple = props.multiple;
|
||||
|
||||
const defaultRenderInput = (params: any) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder="Type to search"
|
||||
/>
|
||||
);
|
||||
|
||||
const autocomplete = (
|
||||
<Autocomplete
|
||||
{...props}
|
||||
disableCloseOnSelect={!!multiple}
|
||||
popupIcon={
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
style={{ marginRight: theme.spacing(3) }}
|
||||
/>
|
||||
}
|
||||
renderInput={renderInput || defaultRenderInput}
|
||||
getOptionKey={(option) => option.id}
|
||||
renderTags={() => null}
|
||||
renderOption={(props, option, { selected }) => {
|
||||
const { key, ...optionProps } = props;
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
{...optionProps}
|
||||
>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
{multiple && <Checkbox checked={selected} />}
|
||||
{option.name}
|
||||
</Stack>
|
||||
</ListItem>
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
|
||||
padding: `0 ${theme.spacing(5)}`,
|
||||
},
|
||||
"& .MuiInputBase-root .MuiAutocomplete-endAdornment": {
|
||||
right: theme.spacing(3),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{autocomplete}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return autocomplete;
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
const theme = useTheme();
|
||||
const outlinedSx =
|
||||
props.variant === "outlined"
|
||||
? {
|
||||
color: theme.palette.text.primary,
|
||||
borderColor: theme.palette.divider,
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.text.secondary,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
fontWeight: 400,
|
||||
borderRadius: 2,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
boxShadow: "none",
|
||||
"&:hover": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
...outlinedSx,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { forwardRef } from "react";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import type { CheckboxProps } from "@mui/material/Checkbox";
|
||||
import { Square, SquareCheck } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type CheckboxInputProps = CheckboxProps & {
|
||||
label?: string;
|
||||
};
|
||||
export const CheckboxInput = forwardRef<HTMLInputElement, CheckboxInputProps>(
|
||||
function CheckboxInput(
|
||||
{ sx, ...props }: CheckboxInputProps,
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Checkbox
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
slotProps={{
|
||||
input: {
|
||||
ref: ref,
|
||||
},
|
||||
}}
|
||||
icon={
|
||||
<Square
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
checkedIcon={
|
||||
<SquareCheck
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-checked": {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"&:hover": { backgroundColor: "transparent" },
|
||||
"& svg": {
|
||||
stroke: "currentColor",
|
||||
},
|
||||
"& svg path, & svg line, & svg polyline, & svg rect": {
|
||||
stroke: "currentColor",
|
||||
fill: "none",
|
||||
},
|
||||
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,48 +0,0 @@
|
||||
import { MuiColorInput } from "mui-color-input";
|
||||
import type { MuiColorInputProps } from "mui-color-input";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||
|
||||
interface ColorPickerProps extends MuiColorInputProps {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps) => {
|
||||
const theme = useTheme();
|
||||
const input = (
|
||||
<MuiColorInput
|
||||
{...props}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{input}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker";
|
||||
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 { Calendar } from "lucide-react";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
|
||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
error?: boolean;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export const DatePickerComponent = ({
|
||||
fieldLabel,
|
||||
required,
|
||||
error,
|
||||
helperText,
|
||||
...props
|
||||
}: DatePickerComponentProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const picker = (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<DatePicker
|
||||
{...props}
|
||||
slots={{
|
||||
openPickerIcon: () => (
|
||||
<Calendar
|
||||
size={20}
|
||||
stroke={theme.palette.text.secondary}
|
||||
/>
|
||||
),
|
||||
...props.slots,
|
||||
}}
|
||||
slotProps={{
|
||||
switchViewButton: { sx: { display: "none" } },
|
||||
nextIconButton: { sx: { ml: theme.spacing(2) } },
|
||||
day: {
|
||||
sx: {
|
||||
"&.MuiPickersDay-root.Mui-disabled": {
|
||||
color: theme.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
field: {
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& > .MuiOutlinedInput-root": {
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
p: 0,
|
||||
pl: theme.spacing(3),
|
||||
pr: theme.spacing(5),
|
||||
},
|
||||
"& fieldset": {
|
||||
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: error ? theme.palette.error.main : theme.palette.divider,
|
||||
},
|
||||
},
|
||||
},
|
||||
inputAdornment: { sx: { ml: 0 } },
|
||||
openPickerButton: {
|
||||
sx: {
|
||||
py: 0,
|
||||
mr: 0,
|
||||
},
|
||||
},
|
||||
...props.slotProps,
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
||||
{picker}
|
||||
{helperText && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={error ? "error" : "text.secondary"}
|
||||
>
|
||||
{helperText}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import type { DialogProps } from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const DialogInput = ({
|
||||
open,
|
||||
title,
|
||||
content,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmColor = "primary",
|
||||
cancelColor = "error",
|
||||
loading = false,
|
||||
cancelText,
|
||||
confirmText,
|
||||
children,
|
||||
maxWidth,
|
||||
fullWidth = false,
|
||||
additionalButtons,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
content?: string;
|
||||
onConfirm?(item: any): any;
|
||||
onCancel?(item: any): any;
|
||||
confirmColor?: "error" | "primary";
|
||||
cancelColor?: "error" | "primary";
|
||||
loading?: boolean;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
children?: ReactNode;
|
||||
maxWidth?: DialogProps["maxWidth"];
|
||||
fullWidth?: boolean;
|
||||
additionalButtons?: ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth={maxWidth}
|
||||
fullWidth={fullWidth}
|
||||
onClose={(_event, reason) => {
|
||||
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
|
||||
}}
|
||||
>
|
||||
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
|
||||
<DialogContent>
|
||||
{content && <DialogContentText>{content}</DialogContentText>}
|
||||
{children}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
color={cancelColor}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText ?? t("common.buttons.cancel")}
|
||||
</Button>
|
||||
{additionalButtons}
|
||||
{onConfirm && (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
color={confirmColor}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText ?? t("common.buttons.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface FieldLabelProps {
|
||||
children: React.ReactNode;
|
||||
required?: boolean;
|
||||
htmlFor?: string;
|
||||
}
|
||||
|
||||
export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelProps) => {
|
||||
const theme = useTheme();
|
||||
if (children === " ") {
|
||||
children = "\u00A0";
|
||||
}
|
||||
return (
|
||||
<Typography
|
||||
component="label"
|
||||
htmlFor={htmlFor}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{required && (
|
||||
<span
|
||||
style={{
|
||||
color: theme.palette.error.main,
|
||||
marginLeft: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Box, Stack, Typography, IconButton } from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Upload, X } from "lucide-react";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileObject {
|
||||
src: string;
|
||||
name: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface ImageUploadProps {
|
||||
src?: string;
|
||||
onChange?: (file: FileObject | undefined) => void;
|
||||
maxSize?: number;
|
||||
accept?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const ImageUpload = ({
|
||||
src,
|
||||
onChange,
|
||||
maxSize = 3 * 1024 * 1024,
|
||||
accept = ["jpg", "jpeg", "png"],
|
||||
error,
|
||||
}: ImageUploadProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<FileObject | null>(null);
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (!file) return;
|
||||
const isValidType = accept.some((type) => file.type.includes(type));
|
||||
const isValidSize = file.size <= maxSize;
|
||||
if (!isValidType) {
|
||||
setLocalError(t("components.imageUpload.errors.invalidFileFormat"));
|
||||
return;
|
||||
}
|
||||
if (!isValidSize) {
|
||||
setLocalError(t("components.imageUpload.errors.invalidFileSize"));
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
const fileObj: FileObject = {
|
||||
src: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
file,
|
||||
};
|
||||
setPreview(fileObj);
|
||||
onChange?.(fileObj);
|
||||
},
|
||||
[maxSize, accept, onChange, t]
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
setPreview(null);
|
||||
setLocalError(null);
|
||||
onChange?.(undefined);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
const displaySrc = src || preview?.src;
|
||||
const displayError = localError || error;
|
||||
|
||||
return (
|
||||
<Stack sx={{ width: "100%", maxWidth: 500 }}>
|
||||
{displaySrc ? (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={displaySrc}
|
||||
alt="Preview"
|
||||
sx={{ maxWidth: 250, maxHeight: 250, objectFit: "contain", borderRadius: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClear}
|
||||
sx={{ color: theme.palette.error.main }}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={1}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
minHeight: 150,
|
||||
border: "2px dashed",
|
||||
borderRadius: 1,
|
||||
borderColor: isDragging ? theme.palette.primary.main : theme.palette.divider,
|
||||
backgroundColor: isDragging ? theme.palette.action.hover : "transparent",
|
||||
transition: "0.2s",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.primary.main,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept.map((ext) => `.${ext}`).join(",")}
|
||||
onChange={(e) => handleFile(e.target.files?.[0])}
|
||||
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<Upload
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
color="currentColor"
|
||||
style={{ opacity: 0.7 }}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
color="primary"
|
||||
fontWeight={500}
|
||||
>
|
||||
{t("components.imageUpload.clickToUpload")}
|
||||
</Typography>{" "}
|
||||
{t("components.imageUpload.orDragAndDrop")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
>
|
||||
{accept.join(", ").toUpperCase()} • {t("components.imageUpload.maxSize")}{" "}
|
||||
{Math.round(maxSize / 1024 / 1024)}MB
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{displayError && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{displayError}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "@/Components/inputs";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { setLanguage } from "@/Features/UI/uiSlice";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
export const languageNames: Record<string, string> = {
|
||||
en: "English",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
"pt-BR": "Português (BR)",
|
||||
cs: "Čeština",
|
||||
fi: "Suomi",
|
||||
tr: "Türkçe",
|
||||
ru: "Русский",
|
||||
uk: "Українська",
|
||||
ar: "العربية",
|
||||
th: "ไทย",
|
||||
vi: "Tiếng Việt",
|
||||
ja: "日本語",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "繁體中文",
|
||||
};
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const { language = "en" } = useSelector((state: RootState) => state.ui);
|
||||
const dispatch = useDispatch();
|
||||
const handleChange = (event: any) => {
|
||||
const newLang = event.target.value;
|
||||
dispatch(setLanguage(newLang));
|
||||
};
|
||||
|
||||
const languages = Object.keys(i18n.options.resources || {});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={language}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<MenuItem
|
||||
key={lang}
|
||||
value={lang}
|
||||
>
|
||||
{languageNames[lang] ?? lang}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,48 +0,0 @@
|
||||
import Radio from "@mui/material/Radio";
|
||||
import type { RadioProps } from "@mui/material/Radio";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
interface RadioInputProps extends RadioProps {}
|
||||
|
||||
export const RadioInput = ({ ...props }: RadioInputProps) => {
|
||||
return <Radio {...props} />;
|
||||
};
|
||||
|
||||
export const RadioWithDescription = ({
|
||||
label,
|
||||
description,
|
||||
...props
|
||||
}: RadioInputProps & { label: string; description: string }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={<RadioInput {...props} />}
|
||||
label={
|
||||
<>
|
||||
<Typography component="p">{label}</Typography>
|
||||
<Typography
|
||||
component="h6"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
sx={{
|
||||
alignItems: "flex-start",
|
||||
p: theme.spacing(2.5),
|
||||
m: theme.spacing(-2.5),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
"& .MuiButtonBase-root": {
|
||||
mr: theme.spacing(6),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import Select from "@mui/material/Select";
|
||||
import type { SelectProps } from "@mui/material/Select";
|
||||
import React, { forwardRef } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const SelectInputInner = <T,>(
|
||||
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const emptyPlaceholderColor = theme.palette.text.secondary;
|
||||
|
||||
const renderValue = (selected: unknown) => {
|
||||
const isMultiple = Boolean((props as { multiple?: boolean }).multiple);
|
||||
const isEmpty = isMultiple
|
||||
? !Array.isArray(selected) || selected.length === 0
|
||||
: selected === undefined || selected === null || selected === "";
|
||||
|
||||
if (isEmpty && placeholder) {
|
||||
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
const items: string[] = Array.isArray(selected) ? selected : [];
|
||||
const capitalized = items.map(
|
||||
(item) => item.charAt(0).toUpperCase() + item.slice(1)
|
||||
);
|
||||
return <Typography>{capitalized.join(" | ")}</Typography>;
|
||||
}
|
||||
|
||||
const nodes = React.Children.toArray(props.children as React.ReactNode);
|
||||
for (const node of nodes) {
|
||||
if (!React.isValidElement(node)) continue;
|
||||
const el = node as React.ReactElement<{
|
||||
value?: unknown;
|
||||
children?: React.ReactNode;
|
||||
}>;
|
||||
if (el.props?.value === selected) {
|
||||
return (el.props.children ?? selected) as React.ReactNode;
|
||||
}
|
||||
}
|
||||
return selected as React.ReactNode;
|
||||
};
|
||||
|
||||
const select = (
|
||||
<Select<T>
|
||||
{...props}
|
||||
ref={ref}
|
||||
displayEmpty
|
||||
renderValue={renderValue}
|
||||
inputProps={{
|
||||
...(props.inputProps || {}),
|
||||
"aria-placeholder": placeholder,
|
||||
}}
|
||||
IconComponent={() => (
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
style={{ marginRight: theme.spacing(3) }}
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
height: "34px",
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
"& .MuiSelect-icon": {
|
||||
right: theme.spacing(3),
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{select}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return select;
|
||||
};
|
||||
|
||||
export const SelectInput = forwardRef(SelectInputInner) as <T>(
|
||||
props: SelectInputProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
|
||||
) => React.ReactElement;
|
||||
Loaded 100 of 1054 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user