Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b09895098 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
|
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Lockfile Check (Client & Server)
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-lockfiles:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Validate with the same node image the production build uses, so a lock
|
|
||||||
# that passes here cannot fail the image build's npm ci.
|
|
||||||
- name: Resolve node image from Dockerfile
|
|
||||||
id: node-image
|
|
||||||
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Check server lockfile
|
|
||||||
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
|
||||||
|
|
||||||
- name: Check client lockfile
|
|
||||||
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: POEditor Translation Synchronization (disabled)
|
||||||
|
|
||||||
|
# This workflow is intentionally disabled — kept for reference only.
|
||||||
|
# The full definition is preserved commented-out below. To re-enable,
|
||||||
|
# delete the stub job and uncomment the section starting at `on:`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
disabled:
|
||||||
|
if: false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "This workflow is disabled. See comments below for the original definition."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# on:
|
||||||
|
# # For manual triggering
|
||||||
|
# workflow_dispatch:
|
||||||
|
# inputs:
|
||||||
|
# languages:
|
||||||
|
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
||||||
|
# required: false
|
||||||
|
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
||||||
|
# format:
|
||||||
|
# description: "Export format (key_value_json or json)"
|
||||||
|
# required: false
|
||||||
|
# default: "key_value_json"
|
||||||
|
|
||||||
|
# permissions:
|
||||||
|
# contents: write
|
||||||
|
# pull-requests: write
|
||||||
|
|
||||||
|
# jobs:
|
||||||
|
# sync-translations:
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# permissions:
|
||||||
|
# contents: write
|
||||||
|
# issues: write
|
||||||
|
# pull-requests: write
|
||||||
|
|
||||||
|
# steps:
|
||||||
|
# - name: Checkout code
|
||||||
|
# uses: actions/checkout@v3
|
||||||
|
# with:
|
||||||
|
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# fetch-depth: 0
|
||||||
|
|
||||||
|
# - name: Setup Node.js
|
||||||
|
# uses: actions/setup-node@v3
|
||||||
|
# with:
|
||||||
|
# node-version: "22"
|
||||||
|
|
||||||
|
# - name: Create package.json for scripts
|
||||||
|
# run: |
|
||||||
|
# mkdir -p .github/scripts
|
||||||
|
# cat > .github/scripts/package.json << EOF
|
||||||
|
# {
|
||||||
|
# "name": "poeditor-scripts",
|
||||||
|
# "version": "1.0.0",
|
||||||
|
# "private": true,
|
||||||
|
# "type": "module",
|
||||||
|
# "dependencies": {
|
||||||
|
# "axios": "^1.6.0",
|
||||||
|
# "fs-extra": "^11.1.1"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# EOF
|
||||||
|
|
||||||
|
# - name: Install dependencies
|
||||||
|
# run: |
|
||||||
|
# cd .github/scripts
|
||||||
|
# npm install
|
||||||
|
|
||||||
|
# - name: Download translations from POEditor
|
||||||
|
# env:
|
||||||
|
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||||
|
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||||
|
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||||
|
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
||||||
|
# run: |
|
||||||
|
# mkdir -p temp
|
||||||
|
# node .github/scripts/download-translations.js
|
||||||
|
|
||||||
|
# - name: Verify translation files
|
||||||
|
# run: |
|
||||||
|
# echo "Verifying translation files..."
|
||||||
|
# for file in temp/*.json; do
|
||||||
|
# echo "Checking $file"
|
||||||
|
# if [ ! -s "$file" ]; then
|
||||||
|
# echo "Error: $file is empty or does not exist"
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
# # Validate JSON format
|
||||||
|
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
||||||
|
# done
|
||||||
|
# echo "All translation files are valid"
|
||||||
|
|
||||||
|
# - name: Copy translations to project
|
||||||
|
# run: |
|
||||||
|
# mkdir -p client/src/locales
|
||||||
|
# cp -r temp/* client/src/locales/
|
||||||
|
# echo "Translation files copied to client/src/locales/"
|
||||||
|
|
||||||
|
# - name: Get current date
|
||||||
|
# id: date
|
||||||
|
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# - name: Format client code
|
||||||
|
# run: |
|
||||||
|
# cd client
|
||||||
|
# npm ci
|
||||||
|
# npm run format
|
||||||
|
|
||||||
|
# - name: Create Pull Request
|
||||||
|
# uses: peter-evans/create-pull-request@v7
|
||||||
|
# with:
|
||||||
|
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# commit-message: "feat: translations updated from POEditor"
|
||||||
|
# title: "🌐 Updated Translations from POEditor"
|
||||||
|
# body: |
|
||||||
|
# This PR contains the latest translations from POEditor.
|
||||||
|
|
||||||
|
# 📅 Update Date: ${{ steps.date.outputs.date }}
|
||||||
|
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||||
|
|
||||||
|
# ⚠️ Please review the translations and approve the PR if everything looks correct.
|
||||||
|
# branch: translation-update-${{ github.run_number }}
|
||||||
|
# delete-branch: true
|
||||||
|
# base: develop
|
||||||
|
# add-paths: |
|
||||||
|
# client/src/locales/*.json
|
||||||
|
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||||
|
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
name: Upload Translations to POEditor on PR Merge
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- "client/src/locales/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-translations:
|
||||||
|
# Only run if the PR was merged (not just closed) or manually triggered
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history to get changed files
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Create package.json for scripts
|
||||||
|
run: |
|
||||||
|
mkdir -p .github/scripts
|
||||||
|
cat > .github/scripts/package.json << EOF
|
||||||
|
{
|
||||||
|
"name": "poeditor-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
|
"form-data": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd .github/scripts
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Get changed locale files
|
||||||
|
id: changed-files
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
# Get base and head commits
|
||||||
|
BASE_SHA=${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA=${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
echo "Base SHA: $BASE_SHA"
|
||||||
|
echo "Head SHA: $HEAD_SHA"
|
||||||
|
|
||||||
|
# Get list of changed files in client/src/locales directory
|
||||||
|
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
|
||||||
|
|
||||||
|
if [ -z "$CHANGED_FILES" ]; then
|
||||||
|
echo "No changes detected in locale files"
|
||||||
|
echo "CHANGED_FILES=" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED_FILES"
|
||||||
|
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$CHANGED_FILES" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload changed translations to POEditor
|
||||||
|
if: env.CHANGED_FILES != ''
|
||||||
|
env:
|
||||||
|
POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||||
|
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||||
|
run: |
|
||||||
|
# Process each changed file
|
||||||
|
for FILE in $CHANGED_FILES; do
|
||||||
|
if [[ -f "$FILE" ]]; then
|
||||||
|
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
|
||||||
|
FILENAME=$(basename "$FILE")
|
||||||
|
|
||||||
|
# Special case: map gb.json to en language code
|
||||||
|
if [ "$FILENAME" == "gb.json" ]; then
|
||||||
|
LANG="en"
|
||||||
|
echo "Found gb.json, mapping to language code 'en'"
|
||||||
|
else
|
||||||
|
LANG=$(basename "$FILE" .json)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Processing $FILE for language $LANG"
|
||||||
|
|
||||||
|
# Upload to POEditor
|
||||||
|
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Notify on success
|
||||||
|
if: success() && env.CHANGED_FILES != ''
|
||||||
|
run: |
|
||||||
|
echo "Successfully uploaded translation files to POEditor."
|
||||||
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on no changes
|
||||||
|
if: env.CHANGED_FILES == ''
|
||||||
|
run: |
|
||||||
|
echo "No translation files were found to upload."
|
||||||
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
|
|||||||
ORIGIN="localhost"
|
ORIGIN="localhost"
|
||||||
LOG_LEVEL="debug"
|
LOG_LEVEL="debug"
|
||||||
```
|
```
|
||||||
|
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
|
||||||
|
|
||||||
### Client `.env`
|
### Client `.env`
|
||||||
```env
|
```env
|
||||||
@@ -109,7 +110,7 @@ client/src/
|
|||||||
- **Data Fetching**: SWR + Axios
|
- **Data Fetching**: SWR + Axios
|
||||||
- **Database**: MongoDB with Mongoose ODM
|
- **Database**: MongoDB with Mongoose ODM
|
||||||
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
||||||
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
|
- **i18n**: i18next + react-i18next (translations via PoEditor)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
|
|||||||
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
|
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
|
||||||
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
|
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
|
||||||
- `DB_CONNECTION_STRING`: MongoDB connection URL
|
- `DB_CONNECTION_STRING`: MongoDB connection URL
|
||||||
- `ORIGIN`: Origin for CORS purposes
|
- `ORIGIN`: Origin for CORS purposes (optional)
|
||||||
- `TOKEN_TTL`: Token time to live (in vercel/ms format)
|
- `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
|
||||||
- `LOG_LEVEL`: Debug level (debug, info, warn, error)
|
- `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
|
||||||
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`)
|
- `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
|
||||||
|
|
||||||
|
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
|
||||||
|
|
||||||
Start the backend server:
|
Start the backend server:
|
||||||
|
|
||||||
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
|
|||||||
|
|
||||||
**Port already in use:**
|
**Port already in use:**
|
||||||
|
|
||||||
- Check if another service is using ports 5173, 52345, 27017, or 6379
|
- Check if another service is using ports 5173, 52345, or 27017
|
||||||
- Stop the conflicting service or change the port in `.env` files
|
- Stop the conflicting service or change the port in `.env` files
|
||||||
|
|
||||||
**MongoDB connection issues:**
|
**MongoDB connection issues:**
|
||||||
|
|||||||
@@ -32,12 +32,12 @@
|
|||||||
|
|
||||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||||
|
|
||||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
|
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=checkmate)
|
[](https://www.pikapods.com/pods?run=checkmate)
|
||||||
|
|
||||||
<img width="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
|
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||||
</p>
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
|
||||||
@@ -80,9 +80,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
|
|||||||
<a id="installation"></a>
|
<a id="installation"></a>
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
|
The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
|
||||||
|
|
||||||
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
||||||
@@ -91,10 +89,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
|||||||
|
|
||||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||||
|
|
||||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
|
||||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
|
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The image is configured entirely through environment variables on the server container:
|
The image is configured entirely through environment variables on the server container:
|
||||||
@@ -157,7 +151,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
- Selective disk monitoring with mountpoint selection
|
- Selective disk monitoring with mountpoint selection
|
||||||
- Incidents at a glance
|
- Incidents at a glance
|
||||||
- Status pages with 4 beautiful themes
|
- Status pages with 4 beautiful themes
|
||||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
@@ -211,9 +205,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
<a id="contributing"></a>
|
<a id="contributing"></a>
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
|
||||||
|
|
||||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
|
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
|
||||||
|
|
||||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
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!
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-34
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
|
||||||
"@hello-pangea/dnd": "^18.0.0",
|
"@hello-pangea/dnd": "^18.0.0",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@mui/lab": "7.0.0",
|
"@mui/lab": "7.0.0",
|
||||||
@@ -93,7 +92,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -409,7 +407,6 @@
|
|||||||
"node_modules/@emotion/react": {
|
"node_modules/@emotion/react": {
|
||||||
"version": "11.14.0",
|
"version": "11.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -447,7 +444,6 @@
|
|||||||
"node_modules/@emotion/styled": {
|
"node_modules/@emotion/styled": {
|
||||||
"version": "11.14.1",
|
"version": "11.14.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -954,15 +950,6 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/geist": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
|
||||||
"license": "OFL-1.1",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@hapi/hoek": {
|
"node_modules/@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
@@ -1236,7 +1223,6 @@
|
|||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "7.3.7",
|
"version": "7.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/core-downloads-tracker": "^7.3.7",
|
"@mui/core-downloads-tracker": "^7.3.7",
|
||||||
@@ -1341,7 +1327,6 @@
|
|||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "7.3.7",
|
"version": "7.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/private-theming": "^7.3.7",
|
"@mui/private-theming": "^7.3.7",
|
||||||
@@ -2386,7 +2371,6 @@
|
|||||||
"version": "24.5.2",
|
"version": "24.5.2",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.12.0"
|
"undici-types": "~7.12.0"
|
||||||
}
|
}
|
||||||
@@ -2402,7 +2386,6 @@
|
|||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2493,7 +2476,6 @@
|
|||||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "7.18.0",
|
"@typescript-eslint/scope-manager": "7.18.0",
|
||||||
"@typescript-eslint/types": "7.18.0",
|
"@typescript-eslint/types": "7.18.0",
|
||||||
@@ -2774,7 +2756,6 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3131,7 +3112,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.42",
|
"baseline-browser-mapping": "^2.10.42",
|
||||||
"caniuse-lite": "^1.0.30001800",
|
"caniuse-lite": "^1.0.30001800",
|
||||||
@@ -3575,8 +3555,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.13",
|
"version": "1.11.13",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -3975,7 +3954,6 @@
|
|||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4788,7 +4766,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -5600,7 +5577,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/geojson-rewind": "^0.5.2",
|
"@mapbox/geojson-rewind": "^0.5.2",
|
||||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
@@ -6382,7 +6358,6 @@
|
|||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -6394,7 +6369,6 @@
|
|||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.71.1",
|
"version": "7.71.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -6598,8 +6572,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/redux-persist": {
|
"node_modules/redux-persist": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
@@ -6749,7 +6722,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.9"
|
||||||
},
|
},
|
||||||
@@ -7591,7 +7563,6 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -7729,7 +7700,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
+2
-5
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.11.0",
|
"version": "3.9.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build-dev": "tsc -b && vite build --mode development",
|
"build-dev": "tsc -b && vite build --mode development",
|
||||||
@@ -12,8 +11,7 @@
|
|||||||
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format-check": "prettier --check .",
|
"format-check": "prettier --check ."
|
||||||
"compile": "tsc -b"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*": "prettier --write --ignore-unknown"
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
@@ -22,7 +20,6 @@
|
|||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
|
||||||
"@hello-pangea/dnd": "^18.0.0",
|
"@hello-pangea/dnd": "^18.0.0",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@mui/lab": "7.0.0",
|
"@mui/lab": "7.0.0",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Menu from "@mui/material/Menu";
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
export type ActionMenuItem = {
|
export type ActionMenuItem = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
@@ -13,7 +12,6 @@ export type ActionMenuItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||||
const theme = useTheme();
|
|
||||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
@@ -29,14 +27,7 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<IconButton
|
<IconButton onClick={handleClick}>
|
||||||
onClick={handleClick}
|
|
||||||
sx={{
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.palette.action.controlHover,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings
|
<Settings
|
||||||
size={20}
|
size={20}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
{t("common.labels.responseTime")}:{" "}
|
{t("common.labels.responseTime")}:{" "}
|
||||||
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
|
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography textTransform={"capitalize"}>
|
<Typography textTransform={"capitalize"}>
|
||||||
Status:{" "}
|
Status:{" "}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
import { computeBarHeights } from "@/Utils/DataUtils";
|
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface HistogramResponseTimeProps {
|
interface HistogramResponseTimeProps {
|
||||||
checks: CheckSnapshot[];
|
checks: CheckSnapshot[];
|
||||||
@@ -20,25 +18,25 @@ interface HistogramResponseTimeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ResponseTimeStats {
|
interface ResponseTimeStats {
|
||||||
max: string | null;
|
max: number | string;
|
||||||
avg: string | null;
|
avg: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HEIGHT = 50;
|
const DEFAULT_HEIGHT = 50;
|
||||||
|
|
||||||
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
||||||
if (!Array.isArray(checks) || checks.length === 0) {
|
if (!Array.isArray(checks) || checks.length === 0) {
|
||||||
return { max: null, avg: null };
|
return { max: "-", avg: "-" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validChecks = checks.filter((check) => check.responseTime != null);
|
const validChecks = checks.filter((check) => check.originalResponseTime != null);
|
||||||
if (validChecks.length === 0) {
|
if (validChecks.length === 0) {
|
||||||
return { max: null, avg: null };
|
return { max: "-", avg: "-" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseTimes = validChecks.map((check) => check.responseTime);
|
const responseTimes = validChecks.map((check) => check.originalResponseTime);
|
||||||
const max = formatMs(Math.max(...responseTimes));
|
const max = Math.round(Math.max(...responseTimes));
|
||||||
const avg = formatMs(
|
const avg = Math.round(
|
||||||
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ export const HistogramResponseTime = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
||||||
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
|
|
||||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
@@ -70,28 +68,29 @@ export const HistogramResponseTime = ({
|
|||||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||||
const gridGap = gap ?? theme.spacing(0.5);
|
const gridGap = gap ?? theme.spacing(0.5);
|
||||||
|
|
||||||
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && (
|
const statsContent = showStats &&
|
||||||
<Stack
|
(typeof stats.max === "number" || typeof stats.avg === "number") && (
|
||||||
justifyContent="center"
|
<Stack
|
||||||
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
|
justifyContent="center"
|
||||||
width={110}
|
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
|
||||||
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
width={110}
|
||||||
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
||||||
>
|
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
>
|
>
|
||||||
{t("common.charts.histogram.avg", { value: stats.avg })}
|
<Typography
|
||||||
</Typography>
|
variant="body2"
|
||||||
<Typography
|
noWrap
|
||||||
variant="body2"
|
>
|
||||||
noWrap
|
{t("common.charts.histogram.avg", { value: stats.avg })}
|
||||||
>
|
</Typography>
|
||||||
{t("common.charts.histogram.max", { value: stats.max })}
|
<Typography
|
||||||
</Typography>
|
variant="body2"
|
||||||
</Stack>
|
noWrap
|
||||||
);
|
>
|
||||||
|
{t("common.charts.histogram.max", { value: stats.max })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
|
|||||||
>
|
>
|
||||||
{data.map((check, index) => {
|
{data.map((check, index) => {
|
||||||
const isPlaceholder = (check as any).status === "placeholder";
|
const isPlaceholder = (check as any).status === "placeholder";
|
||||||
const heightPct = `${barHeights[index] ?? 0}%`;
|
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
||||||
const barColor =
|
const barColor =
|
||||||
check.status === true
|
check.status === true
|
||||||
? theme.palette.success.light
|
? theme.palette.success.light
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMediaQuery } from "@mui/material";
|
import { useMediaQuery } from "@mui/material";
|
||||||
import type { DateRange } from "@/Types/Query";
|
|
||||||
|
|
||||||
interface MonitorTimeFrameHeaderProps {
|
interface MonitorTimeFrameHeaderProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
hasDateRange?: boolean;
|
hasDateRange?: boolean;
|
||||||
dateRange: DateRange;
|
dateRange: string;
|
||||||
setDateRange: (dateRange: DateRange) => void;
|
setDateRange: (dateRange: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderTimeRange = ({
|
export const HeaderTimeRange = ({
|
||||||
@@ -26,7 +25,7 @@ export const HeaderTimeRange = ({
|
|||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
newValue: DateRange | null
|
newValue: string | null
|
||||||
) => {
|
) => {
|
||||||
if (newValue !== null) {
|
if (newValue !== null) {
|
||||||
setDateRange(newValue);
|
setDateRange(newValue);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Box, { type BoxProps } from "@mui/material/Box";
|
import Box, { type BoxProps } from "@mui/material/Box";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { forwardRef } from "react";
|
|
||||||
|
|
||||||
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
|
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={ref}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
@@ -18,6 +16,4 @@ export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, re
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
BaseBox.displayName = "BaseBox";
|
|
||||||
@@ -10,66 +10,39 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
type BaseChartProps = React.PropsWithChildren<{
|
type BaseChartProps = React.PropsWithChildren<{
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
rightTitle?: string;
|
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
maxWidth?: number | string;
|
maxWidth?: number | string;
|
||||||
flexBasis?: number | string;
|
|
||||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||||
onClick?: () => void;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const BaseChart = ({
|
export const BaseChart = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
rightTitle,
|
|
||||||
width = "100%",
|
width = "100%",
|
||||||
maxWidth = "100%",
|
maxWidth = "100%",
|
||||||
flexBasis = "0%",
|
|
||||||
padding,
|
padding,
|
||||||
onClick,
|
|
||||||
}: BaseChartProps) => {
|
}: BaseChartProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<BaseBox
|
<BaseBox
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
sx={{
|
||||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexBasis,
|
|
||||||
width: width,
|
width: width,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
...(onClick && {
|
|
||||||
cursor: "pointer",
|
|
||||||
"& .recharts-wrapper": { cursor: "pointer !important" },
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
flex={1}
|
flex={1}
|
||||||
>
|
>
|
||||||
<Stack
|
<Typography
|
||||||
direction="row"
|
variant="eyebrow"
|
||||||
justifyContent={"space-between"}
|
color="text.secondary"
|
||||||
>
|
>
|
||||||
<Typography
|
{title}
|
||||||
variant="eyebrow"
|
</Typography>
|
||||||
color="text.secondary"
|
|
||||||
sx={{ overflowWrap: "anywhere" }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{rightTitle && (
|
|
||||||
<Typography
|
|
||||||
variant="eyebrow"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ overflowWrap: "anywhere" }}
|
|
||||||
>
|
|
||||||
{rightTitle}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Box flex={1}>{children}</Box>
|
<Box flex={1}>{children}</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
|
|||||||
@@ -83,17 +83,12 @@ export const Breadcrumb = ({
|
|||||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||||
|
|
||||||
const secondSegment = segments[1];
|
const secondSegment = segments[1];
|
||||||
const thirdSegment = segments[2];
|
|
||||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||||
// page/section/:id routes such as docker/host/:monitorId
|
const hasSubPage = isActionPage || isDetailsPage;
|
||||||
const isSectionDetailsPage = Boolean(
|
|
||||||
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
|
|
||||||
);
|
|
||||||
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
|
|
||||||
|
|
||||||
const getSubPageLabel = (): string => {
|
const getSubPageLabel = (): string => {
|
||||||
if (isActionPage || isSectionDetailsPage) {
|
if (isActionPage) {
|
||||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||||
}
|
}
|
||||||
return t("common.breadcrumbs.details");
|
return t("common.breadcrumbs.details");
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
|
|
||||||
export type DotSize = (typeof DotSizes)[number];
|
|
||||||
|
|
||||||
const DOT_SIZE_PX: Record<DotSize, string> = {
|
|
||||||
xs: "2px",
|
|
||||||
sm: "4px",
|
|
||||||
md: "8px",
|
|
||||||
lg: "12px",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DotProps {
|
interface DotProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: DotSize;
|
size?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
|
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
content: '""',
|
content: '""',
|
||||||
width: DOT_SIZE_PX[size],
|
width: size,
|
||||||
height: DOT_SIZE_PX[size],
|
height: size,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
|||||||
() => ({
|
() => ({
|
||||||
circumference: 2 * Math.PI * radius,
|
circumference: 2 * Math.PI * radius,
|
||||||
totalSize: radius * 2 + strokeWidth * 2,
|
totalSize: radius * 2 + strokeWidth * 2,
|
||||||
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
|
strokeLength: (progress / 100) * (2 * Math.PI * radius),
|
||||||
}),
|
}),
|
||||||
[radius, strokeWidth, progressWithinRange]
|
[radius, strokeWidth, progress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [offset, setOffset] = useState(circumference);
|
const [offset, setOffset] = useState(circumference);
|
||||||
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [circumference, strokeLength]);
|
}, [progress, circumference, strokeLength]);
|
||||||
|
|
||||||
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
||||||
|
|
||||||
@@ -123,7 +123,6 @@ export const DetailGauge = ({
|
|||||||
lowerLabel,
|
lowerLabel,
|
||||||
lowerValue,
|
lowerValue,
|
||||||
maxWidth = 225,
|
maxWidth = 225,
|
||||||
flexBasis = "0%",
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -132,7 +131,6 @@ export const DetailGauge = ({
|
|||||||
lowerLabel?: string;
|
lowerLabel?: string;
|
||||||
lowerValue?: string | number;
|
lowerValue?: string | number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
flexBasis?: number | string;
|
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +138,6 @@ export const DetailGauge = ({
|
|||||||
icon={null}
|
icon={null}
|
||||||
title={title}
|
title={title}
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
flexBasis={flexBasis}
|
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ interface IconProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
stroke?: string;
|
stroke?: string;
|
||||||
color?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
size={size}
|
size={size}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
color={color}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
||||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
<Typography>
|
<Typography>
|
||||||
Checking every {formatDuration(monitor?.interval, true)}
|
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: theme.zIndex.snackbar,
|
zIndex: theme.zIndex.snackbar,
|
||||||
backgroundColor: theme.palette.error.dark,
|
backgroundColor: theme.palette.error.main,
|
||||||
color: "#FFFFFF",
|
color: theme.palette.error.contrastText,
|
||||||
px: theme.spacing(8),
|
px: theme.spacing(8),
|
||||||
py: theme.spacing(4),
|
py: theme.spacing(4),
|
||||||
transition: "top 1s ease-in-out",
|
transition: "top 1s ease-in-out",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Link as RouterLink } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ export const PageHeader = ({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
lineHeight: 1.55,
|
lineHeight: 1.55,
|
||||||
maxWidth: "108ch",
|
maxWidth: 640,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
@@ -143,7 +142,7 @@ export const PageHeader = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: INPUT_BASE_HEIGHT,
|
height: 32,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
borderRight:
|
borderRight:
|
||||||
idx < linkItems.length - 1
|
idx < linkItems.length - 1
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Box, Stack, useTheme } from "@mui/material";
|
import { Box, Stack, useTheme } from "@mui/material";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface SkeletonCardProps {
|
interface SkeletonCardProps {
|
||||||
width?: number | string | { xs?: number | string; md?: number | string };
|
width?: number | string | { xs?: number | string; md?: number | string };
|
||||||
@@ -45,7 +44,7 @@ export const SkeletonCard = ({
|
|||||||
|
|
||||||
const blockStyle = {
|
const blockStyle = {
|
||||||
width: 55,
|
width: 55,
|
||||||
height: INPUT_BASE_HEIGHT,
|
height: 34,
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
background: theme.palette.secondary.main,
|
background: theme.palette.secondary.main,
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { darken, lighten } from "@mui/material/styles";
|
import { lighten } from "@mui/material/styles";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||||
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
||||||
@@ -11,18 +11,17 @@ import type { SxProps } from "@mui/material";
|
|||||||
type GradientBox = React.PropsWithChildren<{
|
type GradientBox = React.PropsWithChildren<{
|
||||||
palette?: PaletteKey;
|
palette?: PaletteKey;
|
||||||
sx?: SxProps;
|
sx?: SxProps;
|
||||||
interactive?: boolean;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
|
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const isLight = theme.palette.mode === "light";
|
const isLight = theme.palette.mode === "light";
|
||||||
const paper = theme.palette.background.paper;
|
const paper = theme.palette.background.paper;
|
||||||
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
||||||
const paperEnd = paper;
|
const paperEnd = paper;
|
||||||
const bg = palette
|
const bg = palette
|
||||||
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
||||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,12 +30,6 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
|
|||||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||||
background: bg,
|
background: bg,
|
||||||
...(interactive && {
|
|
||||||
cursor: "pointer",
|
|
||||||
"&:hover": {
|
|
||||||
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -69,8 +62,10 @@ export const StatBox = ({
|
|||||||
return (
|
return (
|
||||||
<GradientBox
|
<GradientBox
|
||||||
palette={palette}
|
palette={palette}
|
||||||
interactive={Boolean(onClick)}
|
sx={{
|
||||||
sx={{ ...(sx as object) }}
|
...(sx as object),
|
||||||
|
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack onClick={onClick}>
|
<Stack onClick={onClick}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SxProps } from "@mui/material";
|
import type { SxProps } from "@mui/material";
|
||||||
|
|
||||||
import { alpha, useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
type StatusBoxProps = React.PropsWithChildren<{
|
type StatusBoxProps = React.PropsWithChildren<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
|||||||
sx={{
|
sx={{
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
|
||||||
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
|
||||||
`,
|
`,
|
||||||
backgroundSize: "24px 24px",
|
backgroundSize: "24px 24px",
|
||||||
maskImage:
|
maskImage:
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { BaseBox } from "@/Components/design-elements";
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
|
|
||||||
import type { MonitorStatus } from "@/Types/Monitor";
|
import type { MonitorStatus } from "@/Types/Monitor";
|
||||||
import type { SxProps } from "@mui/material/styles";
|
import type { SxProps } from "@mui/material/styles";
|
||||||
import {
|
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
||||||
getDockerStatePalette,
|
|
||||||
getStatusPalette,
|
|
||||||
getValuePalette,
|
|
||||||
} from "@/Utils/MonitorUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DockerContainerState } from "@/Types/Check";
|
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||||
export type ValueType = (typeof ValueTypes)[number];
|
export type ValueType = (typeof ValueTypes)[number];
|
||||||
@@ -117,36 +110,3 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
|
|||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DockerStateLabel = ({
|
|
||||||
state,
|
|
||||||
name,
|
|
||||||
image,
|
|
||||||
}: {
|
|
||||||
state: DockerContainerState;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
}) => {
|
|
||||||
const palette = getDockerStatePalette(state);
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={SPACING.LG}
|
|
||||||
alignItems={"center"}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={7}
|
|
||||||
height={7}
|
|
||||||
bgcolor={theme.palette[palette].light}
|
|
||||||
borderRadius="50%"
|
|
||||||
marginRight="5px"
|
|
||||||
/>
|
|
||||||
<Stack>
|
|
||||||
<Typography>{name}</Typography>
|
|
||||||
<Typography variant="body2">{image}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -52,7 +52,6 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
|||||||
emptyViewText?: string;
|
emptyViewText?: string;
|
||||||
emptyViewPositive?: boolean;
|
emptyViewPositive?: boolean;
|
||||||
getRowSx?: (row: T) => SxProps<Theme>;
|
getRowSx?: (row: T) => SxProps<Theme>;
|
||||||
isRowSelected?: (row: T) => boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DataTable<
|
export function DataTable<
|
||||||
@@ -71,7 +70,6 @@ export function DataTable<
|
|||||||
emptyViewText,
|
emptyViewText,
|
||||||
emptyViewPositive,
|
emptyViewPositive,
|
||||||
getRowSx,
|
getRowSx,
|
||||||
isRowSelected,
|
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||||
@@ -81,7 +79,6 @@ export function DataTable<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const isInteractive = Boolean(onRowClick) || expandableRows;
|
|
||||||
|
|
||||||
if (data.length === 0 || headers.length === 0) {
|
if (data.length === 0 || headers.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -111,11 +108,6 @@ export function DataTable<
|
|||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
padding: theme.spacing(LAYOUT.XS),
|
padding: theme.spacing(LAYOUT.XS),
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
cursor: onRowClick ? "pointer" : "default",
|
||||||
...(onRowClick && {
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.palette.action.rowHover,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
@@ -199,7 +191,7 @@ export function DataTable<
|
|||||||
"& :is(th)": {
|
"& :is(th)": {
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||||
@@ -235,25 +227,12 @@ export function DataTable<
|
|||||||
{data.map((row) => {
|
{data.map((row) => {
|
||||||
const key = row.id || row._id || Math.random();
|
const key = row.id || row._id || Math.random();
|
||||||
const isExpanded = expanded === key;
|
const isExpanded = expanded === key;
|
||||||
const selected = isRowSelected?.(row);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
<TableRow
|
<TableRow
|
||||||
sx={{
|
sx={{
|
||||||
cursor: isInteractive ? "pointer" : "default",
|
cursor: onRowClick ? "pointer" : "default",
|
||||||
...(isInteractive && {
|
|
||||||
"&:hover .MuiTableCell-root": {
|
|
||||||
backgroundColor: selected
|
|
||||||
? theme.palette.action.selectedHover
|
|
||||||
: theme.palette.action.rowHover,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(selected && {
|
|
||||||
"& .MuiTableCell-root": {
|
|
||||||
backgroundColor: theme.palette.action.selected,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(getRowSx?.(row) as object),
|
...(getRowSx?.(row) as object),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -313,12 +292,6 @@ interface TablePaginationActionsProps {
|
|||||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.palette.action.controlHover,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { count, page, rowsPerPage, onPageChange } = props;
|
const { count, page, rowsPerPage, onPageChange } = props;
|
||||||
@@ -348,7 +321,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleFirstPageButtonClick}
|
onClick={handleFirstPageButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="first page"
|
aria-label="first page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsRight
|
<ChevronsRight
|
||||||
@@ -366,7 +338,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleBackButtonClick}
|
onClick={handleBackButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="previous page"
|
aria-label="previous page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -384,7 +355,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleNextButtonClick}
|
onClick={handleNextButtonClick}
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
@@ -402,7 +372,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleLastPageButtonClick}
|
onClick={handleLastPageButtonClick}
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
aria-label="last page"
|
aria-label="last page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsLeft
|
<ChevronsLeft
|
||||||
@@ -450,7 +419,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleFirstPageButtonClick}
|
onClick={handleFirstPageButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="first page"
|
aria-label="first page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsRight
|
<ChevronsRight
|
||||||
@@ -468,7 +436,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleBackButtonClick}
|
onClick={handleBackButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="previous page"
|
aria-label="previous page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -486,7 +453,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleNextButtonClick}
|
onClick={handleNextButtonClick}
|
||||||
disabled={hasMore === false}
|
disabled={hasMore === false}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
@@ -504,7 +470,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
<IconButton
|
<IconButton
|
||||||
disabled={hasMore === false}
|
disabled={hasMore === false}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
sx={controlHoverSx(theme)}
|
|
||||||
>
|
>
|
||||||
<Ellipsis
|
<Ellipsis
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import MuiTabs from "@mui/material/Tabs";
|
import MuiTabs from "@mui/material/Tabs";
|
||||||
import type { TabsProps } from "@mui/material/Tabs";
|
import type { TabsProps } from "@mui/material/Tabs";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
interface CustomTabsProps extends TabsProps {}
|
interface CustomTabsProps extends TabsProps {}
|
||||||
|
|
||||||
export const Tabs = (props: CustomTabsProps) => {
|
export const Tabs = (props: CustomTabsProps) => {
|
||||||
@@ -10,7 +8,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
|||||||
return (
|
return (
|
||||||
<MuiTabs
|
<MuiTabs
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: INPUT_BASE_HEIGHT,
|
minHeight: 34,
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
"& .MuiTabs-indicator": {
|
"& .MuiTabs-indicator": {
|
||||||
backgroundColor: theme.palette.primary.main,
|
backgroundColor: theme.palette.primary.main,
|
||||||
@@ -40,9 +38,9 @@ export const Tab = (props: CustomTabProps) => {
|
|||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
fontSize: typographyLevels.m,
|
fontSize: 14,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
minHeight: INPUT_BASE_HEIGHT,
|
minHeight: 34,
|
||||||
padding: theme.spacing(1, 0),
|
padding: theme.spacing(1, 0),
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
minWidth: "auto",
|
minWidth: "auto",
|
||||||
@@ -50,6 +48,7 @@ export const Tab = (props: CustomTabProps) => {
|
|||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type AutoCompleteInputProps = Omit<
|
type AutoCompleteInputProps = Omit<
|
||||||
AutocompleteProps<any, boolean, boolean, boolean>,
|
AutocompleteProps<any, boolean, boolean, boolean>,
|
||||||
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
|
|||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
renderOptionContent?: (option: any) => React.ReactNode;
|
renderOptionContent?: (option: any) => React.ReactNode;
|
||||||
placeholder?: string;
|
|
||||||
error?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AutoCompleteInput = ({
|
export const AutoCompleteInput = ({
|
||||||
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
|
|||||||
required,
|
required,
|
||||||
renderInput,
|
renderInput,
|
||||||
renderOptionContent,
|
renderOptionContent,
|
||||||
placeholder,
|
|
||||||
error,
|
|
||||||
helperText,
|
|
||||||
...props
|
...props
|
||||||
}: AutoCompleteInputProps) => {
|
}: AutoCompleteInputProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
|
||||||
const multiple = props.multiple;
|
const multiple = props.multiple;
|
||||||
const { renderOption: customRenderOption, ...autocompleteProps } = props;
|
const { renderOption: customRenderOption, ...autocompleteProps } = props;
|
||||||
const defaultRenderInput = (params: any) => (
|
const defaultRenderInput = (params: any) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
|
placeholder="Type to search"
|
||||||
error={error}
|
|
||||||
helperText={helperText}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import type { ButtonProps } from "@mui/material/Button";
|
import type { ButtonProps } from "@mui/material/Button";
|
||||||
import { useTheme, darken } from "@mui/material/styles";
|
import { useTheme, darken } from "@mui/material/styles";
|
||||||
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
import { HOVER } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
const PALETTE_COLORS = [
|
const PALETTE_COLORS = [
|
||||||
"primary",
|
"primary",
|
||||||
@@ -43,8 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: INPUT_BASE_HEIGHT,
|
height: 34,
|
||||||
lineHeight: 1.2,
|
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface ColorPickerProps extends MuiColorInputProps {
|
interface ColorPickerProps extends MuiColorInputProps {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -19,7 +18,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
|||||||
sx={{
|
sx={{
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
height: INPUT_BASE_HEIGHT,
|
height: 34,
|
||||||
fontSize: typographyLevels.base,
|
fontSize: typographyLevels.base,
|
||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { FieldLabel } from "./FieldLabel";
|
|||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -56,7 +55,7 @@ export const DatePickerComponent = ({
|
|||||||
flexDirection: "row-reverse",
|
flexDirection: "row-reverse",
|
||||||
},
|
},
|
||||||
"& input": {
|
"& input": {
|
||||||
minHeight: INPUT_BASE_HEIGHT,
|
minHeight: 34,
|
||||||
p: 0,
|
p: 0,
|
||||||
pl: theme.spacing(3),
|
pl: theme.spacing(3),
|
||||||
pr: theme.spacing(5),
|
pr: theme.spacing(5),
|
||||||
@@ -67,9 +66,7 @@ export const DatePickerComponent = ({
|
|||||||
},
|
},
|
||||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||||
{
|
{
|
||||||
borderColor: error
|
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||||
? theme.palette.error.main
|
|
||||||
: theme.palette.text.disabled,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Button } from "@/Components/inputs";
|
|||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SxProps } from "@mui/material/styles";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export const DialogInput = ({
|
export const DialogInput = ({
|
||||||
@@ -26,8 +25,6 @@ export const DialogInput = ({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
additionalButtons,
|
additionalButtons,
|
||||||
cancelSx,
|
|
||||||
onEscape,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -42,14 +39,6 @@ export const DialogInput = ({
|
|||||||
maxWidth?: DialogProps["maxWidth"];
|
maxWidth?: DialogProps["maxWidth"];
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
additionalButtons?: ReactNode;
|
additionalButtons?: ReactNode;
|
||||||
/** Styles the cancel button — e.g. an auto margin to push it left. */
|
|
||||||
cancelSx?: SxProps;
|
|
||||||
/**
|
|
||||||
* Called when Escape is pressed. Defaults to `onCancel`, which is right when
|
|
||||||
* cancel simply dismisses — but not when it does something else, so a dialog
|
|
||||||
* whose cancel slot carries another action passes its own dismiss here.
|
|
||||||
*/
|
|
||||||
onEscape?(): void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -59,12 +48,7 @@ export const DialogInput = ({
|
|||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
onClose={(_event, reason) => {
|
onClose={(_event, reason) => {
|
||||||
if (reason === "backdropClick") return;
|
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
|
||||||
if (onEscape) {
|
|
||||||
onEscape();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (onCancel) onCancel(undefined);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
@@ -110,7 +94,6 @@ export const DialogInput = ({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
sx={cancelSx}
|
|
||||||
>
|
>
|
||||||
{cancelText ?? t("common.buttons.cancel")}
|
{cancelText ?? t("common.buttons.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
|
|||||||
"& .MuiButtonBase-root": {
|
"& .MuiButtonBase-root": {
|
||||||
mr: theme.spacing(6),
|
mr: theme.spacing(6),
|
||||||
},
|
},
|
||||||
"&:has(.Mui-focusVisible)": {
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,17 +6,15 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
helperText?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectInputInner = <T,>(
|
const SelectInputInner = <T,>(
|
||||||
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
|
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
) => {
|
) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -29,7 +27,7 @@ const SelectInputInner = <T,>(
|
|||||||
: selected === undefined || selected === null || selected === "";
|
: selected === undefined || selected === null || selected === "";
|
||||||
|
|
||||||
if (isEmpty && placeholder) {
|
if (isEmpty && placeholder) {
|
||||||
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
|
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMultiple) {
|
if (isMultiple) {
|
||||||
@@ -72,7 +70,7 @@ const SelectInputInner = <T,>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
sx={{
|
sx={{
|
||||||
height: `${INPUT_BASE_HEIGHT}px`,
|
height: "34px",
|
||||||
"& .MuiSelect-select": {
|
"& .MuiSelect-select": {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -84,27 +82,21 @@ const SelectInputInner = <T,>(
|
|||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
},
|
||||||
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
},
|
},
|
||||||
...props.sx,
|
...props.sx,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const helper = helperText ? (
|
if (fieldLabel) {
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
|
|
||||||
>
|
|
||||||
{helperText}
|
|
||||||
</Typography>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (fieldLabel || helper) {
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={theme.spacing(2)}>
|
<Stack spacing={theme.spacing(2)}>
|
||||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||||
{select}
|
{select}
|
||||||
{helper}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -22,10 +22,16 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
|
height: props.multiline ? "auto" : 34,
|
||||||
fontSize: typographyLevels.base,
|
fontSize: typographyLevels.base,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
},
|
||||||
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||||
|
borderColor: theme.palette.divider,
|
||||||
|
},
|
||||||
"& .MuiFormHelperText-root": {
|
"& .MuiFormHelperText-root": {
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
marginRight: 0,
|
marginRight: 0,
|
||||||
@@ -33,7 +39,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|||||||
},
|
},
|
||||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
||||||
{
|
{
|
||||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
|
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
|
||||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||||
caretColor: theme.palette.text.primary,
|
caretColor: theme.palette.text.primary,
|
||||||
transition: "background-color 5000s ease-in-out 0s",
|
transition: "background-color 5000s ease-in-out 0s",
|
||||||
|
|||||||
@@ -3,24 +3,18 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
|
|||||||
import type { Dayjs } from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
error?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimePickerComponent = ({
|
export const TimePickerComponent = ({
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
required,
|
required,
|
||||||
error,
|
|
||||||
helperText,
|
|
||||||
...props
|
...props
|
||||||
}: TimePickerComponentProps) => {
|
}: TimePickerComponentProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -34,17 +28,17 @@ export const TimePickerComponent = ({
|
|||||||
sx: {
|
sx: {
|
||||||
width: "fit-content",
|
width: "fit-content",
|
||||||
"& input": {
|
"& input": {
|
||||||
minHeight: INPUT_BASE_HEIGHT,
|
minHeight: 34,
|
||||||
p: 0,
|
p: 0,
|
||||||
px: theme.spacing(5),
|
px: theme.spacing(5),
|
||||||
},
|
},
|
||||||
"& fieldset": {
|
"& fieldset": {
|
||||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
borderColor: theme.palette.divider,
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
},
|
},
|
||||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||||
{
|
{
|
||||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
borderColor: theme.palette.divider,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -54,18 +48,14 @@ export const TimePickerComponent = ({
|
|||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
if (fieldLabel) {
|
||||||
<Stack spacing={theme.spacing(2)}>
|
return (
|
||||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
<Stack spacing={theme.spacing(2)}>
|
||||||
{picker}
|
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||||
{helperText && (
|
{picker}
|
||||||
<Typography
|
</Stack>
|
||||||
variant="caption"
|
);
|
||||||
color={error ? "error" : "text.secondary"}
|
}
|
||||||
>
|
|
||||||
{helperText}
|
return picker;
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
@@ -4,7 +4,6 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
|||||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { alpha } from "@mui/material/styles";
|
import { alpha } from "@mui/material/styles";
|
||||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -21,7 +20,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|||||||
sx={{
|
sx={{
|
||||||
px: 8,
|
px: 8,
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: INPUT_BASE_HEIGHT,
|
height: 34,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
@@ -35,6 +34,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
backgroundColor: selectedBg,
|
backgroundColor: selectedBg,
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
|
fontWeight: 500,
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: selectedHoverBg,
|
backgroundColor: selectedHoverBg,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
|
||||||
|
|
||||||
export interface AutocompleteOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: O[];
|
|
||||||
fieldLabel?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormAutocompleteField = <
|
|
||||||
T extends FieldValues,
|
|
||||||
O extends AutocompleteOption,
|
|
||||||
>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fieldLabel,
|
|
||||||
placeholder,
|
|
||||||
}: FormAutocompleteProps<T, O>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Autocomplete
|
|
||||||
options={options}
|
|
||||||
getOptionLabel={(option) => option.name}
|
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
||||||
value={options.find((o) => o.id === field.value) ?? null}
|
|
||||||
onChange={(_: unknown, newValue: O | null) => {
|
|
||||||
field.onChange(newValue?.id ?? "");
|
|
||||||
}}
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
placeholder={placeholder}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
|
||||||
import { Checkbox } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormCheckboxFieldProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormCheckboxField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
}: FormCheckboxFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { ColorInput } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormColorFieldProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
fieldLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormColorField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
fieldLabel,
|
|
||||||
}: FormColorFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<ColorInput
|
|
||||||
format="hex"
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import dayjs, { type Dayjs } from "dayjs";
|
|
||||||
import { DatePicker, TimePicker } from "@/Components/inputs";
|
|
||||||
|
|
||||||
export interface FormPickerFieldProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
fieldLabel?: string;
|
|
||||||
format?: string;
|
|
||||||
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DayjsPickerProps {
|
|
||||||
fieldLabel?: string;
|
|
||||||
value: Dayjs | null;
|
|
||||||
onChange: (value: Dayjs | null) => void;
|
|
||||||
error?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormDayjsPickerFieldProps<
|
|
||||||
T extends FieldValues,
|
|
||||||
> extends FormPickerFieldProps<T> {
|
|
||||||
picker: React.ComponentType<DayjsPickerProps>;
|
|
||||||
format: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormDayjsPickerField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
fieldLabel,
|
|
||||||
format,
|
|
||||||
onValueChange,
|
|
||||||
picker: Picker,
|
|
||||||
}: FormDayjsPickerFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Picker
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
value={field.value ? dayjs(field.value, format) : null}
|
|
||||||
onChange={(value) => {
|
|
||||||
const next = value ? value.format(format) : "";
|
|
||||||
field.onChange(next);
|
|
||||||
onValueChange?.(next);
|
|
||||||
}}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FormDatePickerField = <T extends FieldValues>({
|
|
||||||
format = "YYYY-MM-DD",
|
|
||||||
...rest
|
|
||||||
}: FormPickerFieldProps<T>) => (
|
|
||||||
<FormDayjsPickerField
|
|
||||||
{...rest}
|
|
||||||
format={format}
|
|
||||||
picker={DatePicker}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FormTimePickerField = <T extends FieldValues>({
|
|
||||||
format = "HH:mm",
|
|
||||||
...rest
|
|
||||||
}: FormPickerFieldProps<T>) => (
|
|
||||||
<FormDayjsPickerField
|
|
||||||
{...rest}
|
|
||||||
format={format}
|
|
||||||
picker={TimePicker}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Trash2, GripVertical } from "lucide-react";
|
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
|
||||||
import type { DropResult } from "@hello-pangea/dnd";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
|
||||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export interface MultiSelectOption {
|
|
||||||
id: string | number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: O[];
|
|
||||||
fieldLabel?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
description?: string;
|
|
||||||
renderRow?: (option: O) => React.ReactNode;
|
|
||||||
renderOptionContent?: (option: O) => React.ReactNode;
|
|
||||||
sortable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fieldLabel,
|
|
||||||
placeholder,
|
|
||||||
description,
|
|
||||||
renderRow,
|
|
||||||
renderOptionContent,
|
|
||||||
sortable = false,
|
|
||||||
}: FormMultiSelectFieldProps<T, O>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const optionsById = useMemo(
|
|
||||||
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
|
|
||||||
[options]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => {
|
|
||||||
const selectedIds: O["id"][] = field.value ?? [];
|
|
||||||
const selected = selectedIds
|
|
||||||
.map((id) => optionsById.get(id))
|
|
||||||
.filter((o): o is O => o !== undefined);
|
|
||||||
|
|
||||||
const handleDragEnd = (result: DropResult) => {
|
|
||||||
if (!result.destination) return;
|
|
||||||
const reordered = selected.map((o) => o.id);
|
|
||||||
const [moved] = reordered.splice(result.source.index, 1);
|
|
||||||
reordered.splice(result.destination.index, 0, moved);
|
|
||||||
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
|
|
||||||
field.onChange([...reordered, ...unresolved]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const rowContent = (option: O) =>
|
|
||||||
renderRow ? (
|
|
||||||
renderRow(option)
|
|
||||||
) : (
|
|
||||||
<Typography flexGrow={1}>{option.name}</Typography>
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeButton = (option: O) => (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
field.onChange(selectedIds.filter((id) => id !== option.id));
|
|
||||||
}}
|
|
||||||
aria-label={t("common.buttons.removeItem", { item: option.name })}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
autoHighlight
|
|
||||||
options={options}
|
|
||||||
value={selected}
|
|
||||||
getOptionLabel={(option) => option.name}
|
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
||||||
onChange={(_: unknown, newValue: O[]) => {
|
|
||||||
// The Autocomplete only sees resolved options, so ids missing
|
|
||||||
// from options must be re-appended or they'd be silently
|
|
||||||
// dropped (e.g. a role excluded from the selectable list).
|
|
||||||
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
|
|
||||||
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
|
|
||||||
}}
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
placeholder={selected.length > 0 ? "" : placeholder}
|
|
||||||
renderOptionContent={renderOptionContent}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
/>
|
|
||||||
{description && (
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{sortable
|
|
||||||
? selected.length > 0 && (
|
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
|
||||||
<Droppable droppableId={`${name}-list`}>
|
|
||||||
{(provided) => (
|
|
||||||
<Stack
|
|
||||||
{...provided.droppableProps}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
>
|
|
||||||
{selected.map((option, index) => (
|
|
||||||
<Draggable
|
|
||||||
key={option.id}
|
|
||||||
draggableId={String(option.id)}
|
|
||||||
index={index}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<Stack
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
|
||||||
padding={theme.spacing(LAYOUT.XS)}
|
|
||||||
marginTop={theme.spacing(SPACING.LG)}
|
|
||||||
borderRadius={1}
|
|
||||||
border={`1px solid ${theme.palette.divider}`}
|
|
||||||
sx={{
|
|
||||||
cursor: "grab",
|
|
||||||
"&:active": { cursor: "grabbing" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GripVertical size={20} />
|
|
||||||
{rowContent(option)}
|
|
||||||
{removeButton(option)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
)
|
|
||||||
: selected.map((option) => (
|
|
||||||
<Stack
|
|
||||||
key={option.id}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
|
||||||
padding={theme.spacing(LAYOUT.XS)}
|
|
||||||
marginTop={theme.spacing(SPACING.LG)}
|
|
||||||
borderRadius={1}
|
|
||||||
border={`1px solid ${theme.palette.divider}`}
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
{rowContent(option)}
|
|
||||||
{removeButton(option)}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { TextField } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof TextField>,
|
|
||||||
"name" | "error" | "helperText" | "type"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormNumberField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
emptyValue = 0,
|
|
||||||
...rest
|
|
||||||
}: FormNumberFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
type="number"
|
|
||||||
value={
|
|
||||||
field.value === undefined || field.value === emptyValue ? "" : field.value
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
field.onChange(val === "" ? emptyValue : Number(val));
|
|
||||||
}}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
fullWidth
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { RadioGroup, useTheme } from "@mui/material";
|
|
||||||
import FormControl from "@mui/material/FormControl";
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { RadioWithDescription } from "@/Components/inputs/Radio";
|
|
||||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
|
||||||
export interface FormRadioOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormRadioGroupProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: FormRadioOption[];
|
|
||||||
fieldLabel?: string;
|
|
||||||
}
|
|
||||||
export const FormRadioGroup = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fieldLabel,
|
|
||||||
}: FormRadioGroupProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<FormControl error={!!fieldState.error}>
|
|
||||||
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
|
|
||||||
<RadioGroup
|
|
||||||
{...field}
|
|
||||||
sx={{ gap: theme.spacing(LAYOUT.MD) }}
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<RadioWithDescription
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
label={option.label}
|
|
||||||
description={option.description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldValues,
|
|
||||||
type FieldPath,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import { Select } from "@/Components/inputs";
|
|
||||||
|
|
||||||
export interface FormSelectOption {
|
|
||||||
value: string | number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormSelectProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof Select>,
|
|
||||||
"name" | "error" | "value" | "onChange"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: FormSelectOption[];
|
|
||||||
fallbackValue?: string | number; // display value while the field is undefined
|
|
||||||
onValueChange?: (value: string | number) => void; // side effects, see Notes
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSelectField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fallbackValue,
|
|
||||||
onValueChange,
|
|
||||||
...rest
|
|
||||||
}: FormSelectProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? fallbackValue ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(e.target.value);
|
|
||||||
onValueChange?.(e.target.value as string | number);
|
|
||||||
}}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
{...rest}
|
|
||||||
helperText={fieldState.error?.message ?? rest.helperText}
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<MenuItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { SliderWithLabel } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormSliderProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof SliderWithLabel>,
|
|
||||||
"name" | "value" | "onChange"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSliderField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
...rest
|
|
||||||
}: FormSliderProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<SliderWithLabel
|
|
||||||
{...field}
|
|
||||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { SwitchComponent } from "@/Components/inputs";
|
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface FormSwitchProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
label: string;
|
|
||||||
labelPlacement?: "start" | "end"; // left | right
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSwitchField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
labelPlacement = "end",
|
|
||||||
}: FormSwitchProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
|
|
||||||
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
|
|
||||||
>
|
|
||||||
{labelPlacement === "start" ? (
|
|
||||||
<>
|
|
||||||
<Typography>{label}</Typography>
|
|
||||||
<SwitchComponent
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SwitchComponent
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<Typography>{label}</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
|
|
||||||
import { TextField } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormTextFieldProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof TextField>,
|
|
||||||
"name" | "error" | "helperText"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
helperText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormTextField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
...rest
|
|
||||||
}: FormTextFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
type="text"
|
|
||||||
{...rest}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -18,13 +18,15 @@ const RootLayout = () => {
|
|||||||
flex={1}
|
flex={1}
|
||||||
padding={6}
|
padding={6}
|
||||||
overflow={"hidden"}
|
overflow={"hidden"}
|
||||||
bgcolor={
|
sx={{
|
||||||
theme.palette.mode === "dark"
|
backgroundColor:
|
||||||
? "rgba(255, 255, 255, 0.01)"
|
theme.palette.mode === "dark"
|
||||||
: "rgba(124, 116, 116, 0.01)"
|
? "rgba(255, 255, 255, 0.01)"
|
||||||
}
|
: "rgba(0, 0, 0, 0.01)",
|
||||||
alignItems="center"
|
display: "flex",
|
||||||
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12}
|
alignItems: "center",
|
||||||
|
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
maxWidth={1280}
|
maxWidth={1280}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import type { Tag } from "@/Types/Tag";
|
|||||||
import { Typography, useTheme } from "@mui/material";
|
import { Typography, useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
|
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
||||||
const typeDisplayNames: Record<string, string> = {
|
const typeDisplayNames: Record<string, string> = {
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
ping: "Ping",
|
ping: "Ping",
|
||||||
port: "Port",
|
port: "Port",
|
||||||
|
docker: "Docker",
|
||||||
game: "Game",
|
game: "Game",
|
||||||
grpc: "gRPC",
|
grpc: "gRPC",
|
||||||
websocket: "WebSocket",
|
websocket: "WebSocket",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatStatusCode } from "@/Utils/statusCode";
|
import { formatStatusCode } from "@/Utils/statusCode";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface GeoChecksMapProps {
|
interface GeoChecksMapProps {
|
||||||
geoChecks: FlatGeoCheck[];
|
geoChecks: FlatGeoCheck[];
|
||||||
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
return status ? theme.palette.success.main : theme.palette.error.main;
|
return status ? theme.palette.success.main : theme.palette.error.main;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatResponseTime = (timing: number): string => {
|
||||||
|
return `${timing.toFixed(0)}ms`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mapPopupStyles}
|
{mapPopupStyles}
|
||||||
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
>
|
>
|
||||||
Response Time:
|
Response Time:
|
||||||
</Typography>{" "}
|
</Typography>{" "}
|
||||||
{formatMs(selectedCheck.timings.total)}
|
{formatResponseTime(selectedCheck.timings.total)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { HeaderCreate } from "@/Components/common";
|
|
||||||
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
|
|
||||||
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
|
|
||||||
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
|
|
||||||
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
|
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
|
||||||
import type { MonitorListController } from "@/Hooks/useMonitorListController";
|
|
||||||
import { useMediaQuery, useTheme } from "@mui/material";
|
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { TextField, Button, Dialog } from "@/Components/inputs";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pause, Play } from "lucide-react";
|
|
||||||
|
|
||||||
interface MonitorListPageProps {
|
|
||||||
headerKey: string;
|
|
||||||
page: string;
|
|
||||||
actionLink: string;
|
|
||||||
controller: MonitorListController;
|
|
||||||
bulkActions?: boolean;
|
|
||||||
showTypeFilter?: boolean;
|
|
||||||
summaryProps?: { showBreached?: boolean };
|
|
||||||
priorityFallback?: ReactNode;
|
|
||||||
extraLoading?: boolean;
|
|
||||||
extraError?: unknown;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
|
|
||||||
|
|
||||||
export const MonitorListPage = ({
|
|
||||||
headerKey,
|
|
||||||
page,
|
|
||||||
actionLink,
|
|
||||||
controller,
|
|
||||||
bulkActions,
|
|
||||||
showTypeFilter,
|
|
||||||
summaryProps,
|
|
||||||
priorityFallback,
|
|
||||||
extraLoading,
|
|
||||||
extraError,
|
|
||||||
children,
|
|
||||||
}: MonitorListPageProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
|
||||||
const isAdmin = useIsAdmin();
|
|
||||||
const isLoading = controller.isLoading || Boolean(extraLoading);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MonitorBasePageWithStates
|
|
||||||
headerKey={headerKey}
|
|
||||||
loading={isLoading}
|
|
||||||
error={controller.error || extraError}
|
|
||||||
totalCount={controller.effectiveTotalCount}
|
|
||||||
page={page}
|
|
||||||
actionLink={actionLink}
|
|
||||||
priorityFallback={priorityFallback}
|
|
||||||
>
|
|
||||||
<HeaderCreate
|
|
||||||
path={actionLink}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
<HeaderMonitorsSummary
|
|
||||||
summary={controller.summary}
|
|
||||||
{...summaryProps}
|
|
||||||
/>
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
|
||||||
gap={theme.spacing(4)}
|
|
||||||
>
|
|
||||||
<ControlsFilter
|
|
||||||
{...(showTypeFilter === false
|
|
||||||
? { showTypes: false }
|
|
||||||
: {
|
|
||||||
selectedTypes: controller.selectedTypes,
|
|
||||||
setSelectedTypes: controller.setSelectedTypes,
|
|
||||||
})}
|
|
||||||
selectedStatus={controller.selectedStatus}
|
|
||||||
setSelectedStatus={controller.setSelectedStatus}
|
|
||||||
selectedState={controller.selectedState}
|
|
||||||
setSelectedState={controller.setSelectedState}
|
|
||||||
tagOptions={controller.tags ?? []}
|
|
||||||
selectedTags={controller.selectedTags}
|
|
||||||
setSelectedTags={controller.setSelectedTags}
|
|
||||||
onClearFilters={controller.handleClearFilters}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
placeholder={t("pages.uptime.filters.search.placeholder")}
|
|
||||||
value={controller.search}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.setSearch(event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
{controller.selectedTags.length > 0 && (
|
|
||||||
<Stack
|
|
||||||
direction={isSmall ? "column" : "row"}
|
|
||||||
alignItems={isSmall ? "flex-start" : "center"}
|
|
||||||
flexWrap="wrap"
|
|
||||||
gap={theme.spacing(SPACING.XL)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{t("pages.uptime.filters.activeTags")}
|
|
||||||
</Typography>
|
|
||||||
{controller.selectedTags.map((tagId) => {
|
|
||||||
const tag = controller.tags?.find((t) => t.id === tagId);
|
|
||||||
if (!tag) return null;
|
|
||||||
return (
|
|
||||||
<ColoredLabel
|
|
||||||
key={tag.id}
|
|
||||||
text={tag.name}
|
|
||||||
color={tag.color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
{bulkActions && !isLoading && (
|
|
||||||
<BulkActionsBar
|
|
||||||
selectedCount={controller.selectedRows.length}
|
|
||||||
onCancel={controller.handleCancelSelection}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<Play size={16} />}
|
|
||||||
onClick={controller.handleBulkResume}
|
|
||||||
>
|
|
||||||
{t("common.buttons.resume")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<Pause size={16} />}
|
|
||||||
onClick={controller.handleBulkPause}
|
|
||||||
>
|
|
||||||
{t("common.buttons.pause")}
|
|
||||||
</Button>
|
|
||||||
</BulkActionsBar>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
<Dialog
|
|
||||||
open={controller.isDialogOpen}
|
|
||||||
title={t("common.dialogs.delete.title")}
|
|
||||||
content={t("common.dialogs.delete.description")}
|
|
||||||
onConfirm={controller.handleConfirmDelete}
|
|
||||||
onCancel={controller.handleCancelDelete}
|
|
||||||
loading={controller.isDeleting}
|
|
||||||
/>
|
|
||||||
</MonitorBasePageWithStates>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { StatBox } from "@/Components/design-elements";
|
import { StatBox } from "@/Components/design-elements";
|
||||||
|
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
||||||
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface MonitorStatBoxesProps {
|
interface MonitorStatBoxesProps {
|
||||||
monitor?: Monitor;
|
monitor?: Monitor;
|
||||||
monitorStats: MonitorStats | null;
|
monitorStats: MonitorStats | null;
|
||||||
certificateExpiry?: string;
|
certificateExpiry?: string;
|
||||||
domainExpiry?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MonitorStatBoxes = ({
|
export const MonitorStatBoxes = ({
|
||||||
monitor,
|
monitor,
|
||||||
monitorStats,
|
monitorStats,
|
||||||
certificateExpiry,
|
certificateExpiry,
|
||||||
domainExpiry,
|
|
||||||
}: MonitorStatBoxesProps) => {
|
}: MonitorStatBoxesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
|
|||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
||||||
|
|
||||||
const streakTime = formatDuration(timeSinceLastFailure);
|
const options = {
|
||||||
|
secondsDecimalDigits: 0,
|
||||||
|
millisecondsDecimalDigits: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const lastCheckTime = formatDuration(timeSinceLastCheck);
|
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
|
||||||
|
|
||||||
|
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
|
||||||
const isActive =
|
const isActive =
|
||||||
monitor?.status === "up" ||
|
monitor?.status === "up" ||
|
||||||
monitor?.status === "paused" ||
|
monitor?.status === "paused" ||
|
||||||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
|
|||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
||||||
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)}
|
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{monitor?.type === "http" && (
|
{monitor?.type === "http" && (
|
||||||
<>
|
<StatBox
|
||||||
<StatBox
|
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
subtitle={certificateExpiry || "N/A"}
|
||||||
subtitle={certificateExpiry || "N/A"}
|
/>
|
||||||
/>
|
|
||||||
<StatBox
|
|
||||||
title={t("pages.common.monitors.statBoxes.domainExpiry")}
|
|
||||||
subtitle={domainExpiry || "N/A"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
export const computeYAxisCap = (values: number[]): number | undefined => {
|
|
||||||
const sorted = values.slice().sort((a, b) => a - b);
|
|
||||||
if (sorted.length < 2) return undefined;
|
|
||||||
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
|
||||||
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PHASE_KEYS = [
|
|
||||||
"avgDns",
|
|
||||||
"avgTcp",
|
|
||||||
"avgTls",
|
|
||||||
"avgRequest",
|
|
||||||
"avgFirstByte",
|
|
||||||
"avgDownload",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const PHASE_COLOR_KEYS = {
|
|
||||||
avgDns: "dns",
|
|
||||||
avgTcp: "tcp",
|
|
||||||
avgTls: "tls",
|
|
||||||
avgRequest: "request",
|
|
||||||
avgFirstByte: "firstByte",
|
|
||||||
avgDownload: "download",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// i18n keys, resolved with t() at render
|
|
||||||
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
|
||||||
avgDns: "common.charts.phases.dns",
|
|
||||||
avgTcp: "common.charts.phases.tcp",
|
|
||||||
avgTls: "common.charts.phases.tls",
|
|
||||||
avgRequest: "common.charts.phases.request",
|
|
||||||
avgFirstByte: "common.charts.phases.firstByte",
|
|
||||||
avgDownload: "common.charts.phases.download",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AREA_COLORS = [
|
|
||||||
// Blues
|
|
||||||
"#3182bd", // Deep blue
|
|
||||||
"#6baed6", // Medium blue
|
|
||||||
"#9ecae1", // Light blue
|
|
||||||
|
|
||||||
// Greens
|
|
||||||
"#74c476", // Soft green
|
|
||||||
"#a1d99b", // Light green
|
|
||||||
"#c7e9c0", // Pale green
|
|
||||||
|
|
||||||
// Oranges
|
|
||||||
"#fdae6b", // Warm orange
|
|
||||||
"#fdd0a2", // Light orange
|
|
||||||
"#feedde", // Pale orange
|
|
||||||
|
|
||||||
// Purples
|
|
||||||
"#9467bd", // Lavender
|
|
||||||
"#a55194", // Deep magenta
|
|
||||||
"#c994c7", // Soft magenta
|
|
||||||
|
|
||||||
// Reds
|
|
||||||
"#ff9896", // Soft red
|
|
||||||
"#de2d26", // Deep red
|
|
||||||
"#fc9272", // Medium red
|
|
||||||
|
|
||||||
// Cyans/Teals
|
|
||||||
"#17becf", // Cyan
|
|
||||||
"#7fcdbb", // Teal
|
|
||||||
"#a1dab4", // Light teal
|
|
||||||
|
|
||||||
// Yellows
|
|
||||||
"#fec44f", // Mustard
|
|
||||||
"#fee391", // Light yellow
|
|
||||||
"#ffffd4", // Pale yellow
|
|
||||||
|
|
||||||
// Additional colors
|
|
||||||
"#e377c2", // Soft pink
|
|
||||||
"#bcbd22", // Olive
|
|
||||||
"#2ca02c", // Vibrant green
|
|
||||||
];
|
|
||||||
|
|
||||||
export const createGradient = ({
|
|
||||||
id,
|
|
||||||
startColor,
|
|
||||||
endColor,
|
|
||||||
startOpacity = 0.8,
|
|
||||||
endOpacity = 0,
|
|
||||||
direction = "vertical",
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
startColor: string;
|
|
||||||
endColor: string;
|
|
||||||
startOpacity?: number;
|
|
||||||
endOpacity?: number;
|
|
||||||
direction?: "vertical" | "horizontal";
|
|
||||||
}) => (
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id={id}
|
|
||||||
x1={direction === "vertical" ? "0" : "0"}
|
|
||||||
y1={direction === "vertical" ? "0" : "0"}
|
|
||||||
x2={direction === "vertical" ? "0" : "1"}
|
|
||||||
y2={direction === "vertical" ? "1" : "0"}
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={startColor}
|
|
||||||
stopOpacity={startOpacity}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={endColor}
|
|
||||||
stopOpacity={endOpacity}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
);
|
|
||||||
@@ -1,39 +1,53 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
Text,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useTranslation } from "react-i18next";
|
import {
|
||||||
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
|
formatDateWithTz,
|
||||||
|
tickDateFormatLookup,
|
||||||
|
tooltipDateFormatLookup,
|
||||||
|
} from "@/Utils/TimeUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { Theme } from "@mui/material/styles";
|
import type { Theme } from "@mui/material/styles";
|
||||||
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
|
import type { GroupedCheck } from "@/Types/Check";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
type XTickProps = {
|
||||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
x: number;
|
||||||
import {
|
y: number;
|
||||||
PHASE_COLOR_KEYS,
|
payload: { value: string };
|
||||||
PHASE_KEYS,
|
range: string;
|
||||||
PHASE_LABEL_KEYS,
|
};
|
||||||
} from "@/Components/monitors/charts/ChartUtils";
|
|
||||||
|
|
||||||
type DetailsCheck = GroupedCheck &
|
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
||||||
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
|
const format = tickDateFormatLookup(range);
|
||||||
|
const theme = useTheme();
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
x={x}
|
||||||
|
y={y + 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={theme.palette.text.secondary}
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={400}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
range: string;
|
range: string;
|
||||||
@@ -49,65 +63,17 @@ const ResponseTimeToolTip = ({
|
|||||||
theme,
|
theme,
|
||||||
uiTimezone,
|
uiTimezone,
|
||||||
}: ResponseTimeToolTipProps) => {
|
}: ResponseTimeToolTipProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
const format = tooltipDateFormatLookup(range);
|
||||||
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
|
console.log(payload);
|
||||||
|
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
||||||
return (
|
return (
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||||
<Typography>
|
<Typography>Response time: {responseTime} ms</Typography>
|
||||||
{t("common.charts.histogram.responseTime", {
|
|
||||||
value: formatMs(responseTime),
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
|
|
||||||
range: string;
|
|
||||||
theme: Theme;
|
|
||||||
uiTimezone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TimingPhasesToolTip = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
range,
|
|
||||||
theme,
|
|
||||||
uiTimezone,
|
|
||||||
}: TimingPhaseToolTipProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
const bucket = payload[0].payload as GroupedUptimeCheck;
|
|
||||||
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
|
||||||
return (
|
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
|
||||||
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
|
|
||||||
<Typography>
|
|
||||||
{t("common.charts.histogram.total", { value: formatMs(total) })}
|
|
||||||
</Typography>
|
|
||||||
{PHASE_KEYS.map((key) => (
|
|
||||||
<Stack
|
|
||||||
alignItems={"center"}
|
|
||||||
direction="row"
|
|
||||||
key={key}
|
|
||||||
gap={SPACING.LG}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
size="lg"
|
|
||||||
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
/>
|
|
||||||
<Typography>
|
|
||||||
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -116,28 +82,11 @@ export const HistogramDetails = ({
|
|||||||
checks,
|
checks,
|
||||||
range,
|
range,
|
||||||
}: {
|
}: {
|
||||||
checks: DetailsCheck[];
|
checks: GroupedCheck[];
|
||||||
range: string;
|
range: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [showTimingPhases, setShowTimingPhases] = useState(false);
|
|
||||||
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
const yMax = useMemo(() => {
|
|
||||||
const totals = checks.map((check) => {
|
|
||||||
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
|
|
||||||
return Math.max(check.avgResponseTime ?? 0, phaseSum);
|
|
||||||
});
|
|
||||||
return computeYAxisCap(totals);
|
|
||||||
}, [checks]);
|
|
||||||
|
|
||||||
const hasTimingPhases = useMemo(
|
|
||||||
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
|
|
||||||
[checks]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseChart
|
<BaseChart
|
||||||
icon={
|
icon={
|
||||||
@@ -146,15 +95,7 @@ export const HistogramDetails = ({
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={
|
title="Response times"
|
||||||
showTimingPhases
|
|
||||||
? t("common.charts.labels.timingPhases")
|
|
||||||
: t("common.charts.labels.responseTime")
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (!hasTimingPhases) return;
|
|
||||||
setShowTimingPhases((prev) => !prev);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ResponsiveContainer
|
<ResponsiveContainer
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -187,34 +128,7 @@ export const HistogramDetails = ({
|
|||||||
stopOpacity={0}
|
stopOpacity={0}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
{PHASE_KEYS.map((key) => (
|
|
||||||
<linearGradient
|
|
||||||
key={key}
|
|
||||||
id={`phaseGradient-${key}`}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
stopOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
))}
|
|
||||||
</defs>
|
</defs>
|
||||||
<YAxis
|
|
||||||
hide
|
|
||||||
// scale="sqrt"
|
|
||||||
domain={[0, yMax ?? "auto"]}
|
|
||||||
allowDataOverflow
|
|
||||||
/>
|
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -227,99 +141,24 @@ export const HistogramDetails = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showTimingPhases ? (
|
<Tooltip
|
||||||
<Tooltip
|
content={(props) => (
|
||||||
content={(props) => (
|
<ResponseTimeToolTip
|
||||||
<TimingPhasesToolTip
|
{...props}
|
||||||
{...props}
|
range={range}
|
||||||
range={range}
|
theme={theme}
|
||||||
theme={theme}
|
uiTimezone={uiTimezone}
|
||||||
uiTimezone={uiTimezone}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
<Area
|
||||||
) : (
|
type="monotone"
|
||||||
<Tooltip
|
dataKey="avgResponseTime"
|
||||||
content={(props) => (
|
stroke={theme.palette.primary.main}
|
||||||
<ResponseTimeToolTip
|
fill="url(#colorUv)"
|
||||||
{...props}
|
/>
|
||||||
range={range}
|
|
||||||
theme={theme}
|
|
||||||
uiTimezone={uiTimezone}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!showTimingPhases && (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="avgResponseTime"
|
|
||||||
stroke={theme.palette.primary.main}
|
|
||||||
fill="url(#colorUv)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasTimingPhases &&
|
|
||||||
showTimingPhases &&
|
|
||||||
PHASE_KEYS.map((key) => {
|
|
||||||
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={key}
|
|
||||||
type="monotone"
|
|
||||||
stackId={1}
|
|
||||||
dataKey={key}
|
|
||||||
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
fill={`url(#phaseGradient-${key})`}
|
|
||||||
fillOpacity={dimmed ? 0.2 : undefined}
|
|
||||||
strokeOpacity={dimmed ? 0.2 : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<Stack
|
|
||||||
justifyContent={"center"}
|
|
||||||
gap={LAYOUT.XS}
|
|
||||||
direction={"row"}
|
|
||||||
flexWrap={"wrap"}
|
|
||||||
>
|
|
||||||
{showTimingPhases ? (
|
|
||||||
PHASE_KEYS.map((key) => {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={SPACING.LG}
|
|
||||||
key={key}
|
|
||||||
onMouseEnter={() => setHoveredPhase(key)}
|
|
||||||
onMouseLeave={() => setHoveredPhase(null)}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={SPACING.LG}
|
|
||||||
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
|
|
||||||
onMouseLeave={() => setHoveredPhase(null)}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={theme.palette.primary.main}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<Typography lineHeight={1}>
|
|
||||||
{t("common.charts.labels.totalResponseTime")}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
import { Fragment, useId } from "react";
|
|
||||||
|
|
||||||
// Utils
|
|
||||||
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
|
||||||
interface HistogramDockerContainerProps {
|
|
||||||
title: string;
|
|
||||||
rightTitle: string;
|
|
||||||
stats: DockerContainerStats;
|
|
||||||
dataKey: string;
|
|
||||||
strokeColor: string;
|
|
||||||
gradientStartColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HistogramDockerContainer = ({
|
|
||||||
title,
|
|
||||||
rightTitle,
|
|
||||||
stats,
|
|
||||||
dataKey,
|
|
||||||
strokeColor,
|
|
||||||
gradientStartColor,
|
|
||||||
}: HistogramDockerContainerProps) => {
|
|
||||||
const uniqueId = useId();
|
|
||||||
const theme = useTheme();
|
|
||||||
const gradientId = `gradient-${uniqueId}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseChart
|
|
||||||
icon="null"
|
|
||||||
title={title}
|
|
||||||
rightTitle={rightTitle}
|
|
||||||
>
|
|
||||||
<ResponsiveContainer
|
|
||||||
width="100%"
|
|
||||||
height={200}
|
|
||||||
>
|
|
||||||
<AreaChart data={stats.aggregate}>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeWidth={1}
|
|
||||||
stroke={theme.palette.divider}
|
|
||||||
strokeOpacity={1}
|
|
||||||
fill="transparent"
|
|
||||||
vertical={false}
|
|
||||||
/>
|
|
||||||
<Fragment>
|
|
||||||
{createGradient({
|
|
||||||
id: gradientId,
|
|
||||||
startColor: gradientStartColor,
|
|
||||||
endColor: "transparent",
|
|
||||||
direction: "vertical",
|
|
||||||
})}
|
|
||||||
<Area
|
|
||||||
dataKey={dataKey}
|
|
||||||
type="monotone"
|
|
||||||
stroke={strokeColor}
|
|
||||||
fill={`url(#${gradientId})`}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</BaseChart>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -8,11 +8,89 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Fragment, useId } from "react";
|
import { Fragment, useId } from "react";
|
||||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
import { XTick } from "@/Components/monitors";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||||
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
|
|
||||||
|
const AREA_COLORS = [
|
||||||
|
// Blues
|
||||||
|
"#3182bd", // Deep blue
|
||||||
|
"#6baed6", // Medium blue
|
||||||
|
"#9ecae1", // Light blue
|
||||||
|
|
||||||
|
// Greens
|
||||||
|
"#74c476", // Soft green
|
||||||
|
"#a1d99b", // Light green
|
||||||
|
"#c7e9c0", // Pale green
|
||||||
|
|
||||||
|
// Oranges
|
||||||
|
"#fdae6b", // Warm orange
|
||||||
|
"#fdd0a2", // Light orange
|
||||||
|
"#feedde", // Pale orange
|
||||||
|
|
||||||
|
// Purples
|
||||||
|
"#9467bd", // Lavender
|
||||||
|
"#a55194", // Deep magenta
|
||||||
|
"#c994c7", // Soft magenta
|
||||||
|
|
||||||
|
// Reds
|
||||||
|
"#ff9896", // Soft red
|
||||||
|
"#de2d26", // Deep red
|
||||||
|
"#fc9272", // Medium red
|
||||||
|
|
||||||
|
// Cyans/Teals
|
||||||
|
"#17becf", // Cyan
|
||||||
|
"#7fcdbb", // Teal
|
||||||
|
"#a1dab4", // Light teal
|
||||||
|
|
||||||
|
// Yellows
|
||||||
|
"#fec44f", // Mustard
|
||||||
|
"#fee391", // Light yellow
|
||||||
|
"#ffffd4", // Pale yellow
|
||||||
|
|
||||||
|
// Additional colors
|
||||||
|
"#e377c2", // Soft pink
|
||||||
|
"#bcbd22", // Olive
|
||||||
|
"#2ca02c", // Vibrant green
|
||||||
|
];
|
||||||
|
|
||||||
|
const createGradient = ({
|
||||||
|
id,
|
||||||
|
startColor,
|
||||||
|
endColor,
|
||||||
|
startOpacity = 0.8,
|
||||||
|
endOpacity = 0,
|
||||||
|
direction = "vertical",
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
startColor: string;
|
||||||
|
endColor: string;
|
||||||
|
startOpacity?: number;
|
||||||
|
endOpacity?: number;
|
||||||
|
direction?: "vertical" | "horizontal";
|
||||||
|
}) => (
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={id}
|
||||||
|
x1={direction === "vertical" ? "0" : "0"}
|
||||||
|
y1={direction === "vertical" ? "0" : "0"}
|
||||||
|
x2={direction === "vertical" ? "0" : "1"}
|
||||||
|
y2={direction === "vertical" ? "1" : "0"}
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={startColor}
|
||||||
|
stopOpacity={startOpacity}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={endColor}
|
||||||
|
stopOpacity={endOpacity}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
);
|
||||||
|
|
||||||
export const HistogramInfrastructure = ({
|
export const HistogramInfrastructure = ({
|
||||||
dateRange,
|
dateRange,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { TrendingUp } from "lucide-react";
|
import { TrendingUp } from "lucide-react";
|
||||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
import { XTick } from "@/Components/monitors";
|
||||||
import {
|
import {
|
||||||
XAxis,
|
XAxis,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||||
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography variant="caption">{titleText}</Typography>
|
<Typography variant="caption">{titleText}</Typography>
|
||||||
<Typography variant="body2">{formatMs(avg)}</Typography>
|
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -85,9 +84,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
|
|||||||
<Typography
|
<Typography
|
||||||
variant="h1"
|
variant="h1"
|
||||||
textAlign={"center"}
|
textAlign={"center"}
|
||||||
>
|
>{`${avg?.toFixed()}ms`}</Typography>
|
||||||
{formatMs(avg)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { Text } from "recharts";
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
|
|
||||||
type XTickProps = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
payload: { value: string };
|
|
||||||
range: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
|
||||||
const format = tickDateFormatLookup(range);
|
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
x={x}
|
|
||||||
y={y + 10}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill={theme.palette.text.secondary}
|
|
||||||
fontSize={11}
|
|
||||||
fontWeight={400}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
|||||||
export * from "./charts/HistogramInfrastructure";
|
export * from "./charts/HistogramInfrastructure";
|
||||||
export * from "./HeaderMonitorsSummary";
|
export * from "./HeaderMonitorsSummary";
|
||||||
export * from "./BulkActionsBar";
|
export * from "./BulkActionsBar";
|
||||||
export * from "./MonitorListPage";
|
|
||||||
@@ -10,11 +10,10 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react";
|
import { MoreVertical, LogOut } from "lucide-react";
|
||||||
import { Avatar, Icon } from "@/Components/design-elements";
|
import { Avatar, Icon } from "@/Components/design-elements";
|
||||||
import { clearAuthState } from "@/Features/Auth/authSlice.js";
|
import { clearAuthState } from "@/Features/Auth/authSlice.js";
|
||||||
import type { RootState } from "@/Types/state.js";
|
import type { RootState } from "@/Types/state.js";
|
||||||
import { setMode } from "@/Features/UI/uiSlice.js";
|
|
||||||
|
|
||||||
interface AuthFooterProps {
|
interface AuthFooterProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -28,7 +27,6 @@ interface AuthFooterProps {
|
|||||||
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
|
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const mode = useSelector((state: RootState) => state.ui.mode);
|
|
||||||
const user = useSelector((state: RootState) => state.auth.user);
|
const user = useSelector((state: RootState) => state.auth.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
<Typography
|
<Typography
|
||||||
fontWeight={500}
|
fontWeight={500}
|
||||||
noWrap
|
noWrap
|
||||||
color={theme.palette.text.primary}
|
sx={{ color: theme.palette.text.primary }}
|
||||||
>
|
>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
noWrap
|
||||||
textTransform={"capitalize"}
|
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
>
|
>
|
||||||
{getRoleText()}
|
{getRoleText()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
pb={2}
|
pb={2}
|
||||||
sx={{ pointerEvents: "none" }}
|
sx={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
<Typography fontWeight={500}>
|
<Typography
|
||||||
|
fontWeight={500}
|
||||||
|
fontSize={13}
|
||||||
|
>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
fontSize={12}
|
||||||
textTransform={"capitalize"}
|
sx={{ textTransform: "capitalize" }}
|
||||||
>
|
>
|
||||||
{getRoleText()}
|
{getRoleText()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
{item.name}
|
{item.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(setMode(mode === "light" ? "dark" : "light"));
|
|
||||||
handleMenuClose();
|
|
||||||
}}
|
|
||||||
sx={menuItemSx}
|
|
||||||
>
|
|
||||||
<Icon icon={mode === "light" ? Moon : Sun} />
|
|
||||||
{mode === "light"
|
|
||||||
? t("pages.settings.form.ui.option.theme.dark")
|
|
||||||
: t("pages.settings.form.ui.option.theme.light")}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
sx={menuItemSx}
|
sx={menuItemSx}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Icon } from "@/Components/design-elements";
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
|
||||||
Globe,
|
Globe,
|
||||||
Gauge,
|
Gauge,
|
||||||
Link,
|
Link,
|
||||||
@@ -19,100 +18,64 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Users,
|
Users,
|
||||||
Tag,
|
Tag,
|
||||||
Waypoints,
|
|
||||||
Container,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const getMenu = (t: Function) => {
|
export const getMenu = (t: Function) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.monitoring"),
|
name: t("components.sidebar.menu.uptime"),
|
||||||
items: [
|
path: "uptime",
|
||||||
{
|
icon: <Icon icon={Globe} />,
|
||||||
name: t("components.sidebar.menu.dashboard"),
|
|
||||||
path: "dashboard",
|
|
||||||
icon: <Icon icon={LayoutDashboard} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.uptime"),
|
|
||||||
path: "uptime",
|
|
||||||
icon: <Icon icon={Globe} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.docker"),
|
|
||||||
path: "docker",
|
|
||||||
icon: <Icon icon={Container} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.infrastructure"),
|
|
||||||
path: "infrastructure",
|
|
||||||
icon: <Icon icon={Link} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.pagespeed"),
|
|
||||||
path: "pagespeed",
|
|
||||||
icon: <Icon icon={Gauge} />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.alerting"),
|
name: t("components.sidebar.menu.pagespeed"),
|
||||||
items: [
|
path: "pagespeed",
|
||||||
{
|
icon: <Icon icon={Gauge} />,
|
||||||
name: t("components.sidebar.menu.incidents"),
|
|
||||||
path: "incidents",
|
|
||||||
icon: <Icon icon={AlertTriangle} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.notifications"),
|
|
||||||
path: "notifications",
|
|
||||||
icon: <Icon icon={Bell} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.maintenance"),
|
|
||||||
path: "maintenance",
|
|
||||||
icon: <Icon icon={Wrench} />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.reporting"),
|
name: t("components.sidebar.menu.infrastructure"),
|
||||||
items: [
|
path: "infrastructure",
|
||||||
{
|
icon: <Icon icon={Link} />,
|
||||||
name: t("components.sidebar.menu.statusPages"),
|
|
||||||
path: "status",
|
|
||||||
icon: <Icon icon={Wifi} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.checks"),
|
|
||||||
path: "checks",
|
|
||||||
icon: <Icon icon={FileText} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.logs"),
|
|
||||||
path: "logs",
|
|
||||||
icon: <Icon icon={ScrollText} />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.configuration"),
|
name: t("components.sidebar.menu.notifications"),
|
||||||
items: [
|
path: "notifications",
|
||||||
{
|
icon: <Icon icon={Bell} />,
|
||||||
name: t("components.sidebar.menu.tags"),
|
},
|
||||||
path: "tags",
|
{
|
||||||
icon: <Icon icon={Tag} />,
|
name: t("components.sidebar.menu.tags"),
|
||||||
},
|
path: "tags",
|
||||||
{
|
icon: <Icon icon={Tag} />,
|
||||||
name: t("components.sidebar.menu.proxies"),
|
},
|
||||||
path: "proxies",
|
{
|
||||||
icon: <Icon icon={Waypoints} />,
|
name: t("components.sidebar.menu.checks"),
|
||||||
},
|
path: "checks",
|
||||||
{
|
icon: <Icon icon={FileText} />,
|
||||||
name: t("components.sidebar.menu.settings"),
|
},
|
||||||
path: "settings",
|
{
|
||||||
icon: <Icon icon={Settings} />,
|
name: t("components.sidebar.menu.incidents"),
|
||||||
},
|
path: "incidents",
|
||||||
],
|
icon: <Icon icon={AlertTriangle} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.statusPages"),
|
||||||
|
path: "status",
|
||||||
|
icon: <Icon icon={Wifi} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.maintenance"),
|
||||||
|
path: "maintenance",
|
||||||
|
icon: <Icon icon={Wrench} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.logs"),
|
||||||
|
path: "logs",
|
||||||
|
icon: <Icon icon={ScrollText} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("components.sidebar.menu.settings"),
|
||||||
|
icon: <Icon icon={Settings} />,
|
||||||
|
path: "settings",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ export const NavItem = ({
|
|||||||
const iconStroke = selected
|
const iconStroke = selected
|
||||||
? theme.palette.sidebar.accent
|
? theme.palette.sidebar.accent
|
||||||
: theme.palette.text.secondary;
|
: theme.palette.text.secondary;
|
||||||
|
|
||||||
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
||||||
const buttonBgHoverColor = selected
|
const buttonBgHoverColor = selected
|
||||||
? theme.palette.action.selectedHover
|
? theme.palette.action.selected
|
||||||
: theme.palette.action.rowHover;
|
: theme.palette.action.hover;
|
||||||
const fontWeight = selected ? 600 : 400;
|
const fontWeight = selected ? 600 : 400;
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Backdrop from "@mui/material/Backdrop";
|
import Backdrop from "@mui/material/Backdrop";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
|
||||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||||
import { Logo } from "@/Components/sidebar/Logo";
|
import { Logo } from "@/Components/sidebar/Logo";
|
||||||
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
||||||
@@ -32,7 +29,7 @@ export const Sidebar = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { width, transition, transitionTiming, collapsed } = useSidebar();
|
const { width, transition, collapsed } = useSidebar();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const menu = getMenu(t);
|
const menu = getMenu(t);
|
||||||
@@ -73,11 +70,11 @@ export const Sidebar = () => {
|
|||||||
gap={theme.spacing(LAYOUT.SM)}
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
borderRight={`1px solid ${theme.palette.divider}`}
|
borderRight={`1px solid ${theme.palette.divider}`}
|
||||||
width={width}
|
width={width}
|
||||||
bgcolor={theme.palette.background.paper}
|
|
||||||
sx={{
|
sx={{
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
transition: transition,
|
transition: transition,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<List
|
<List
|
||||||
@@ -92,55 +89,19 @@ export const Sidebar = () => {
|
|||||||
pt={theme.spacing(LAYOUT.MD)}
|
pt={theme.spacing(LAYOUT.MD)}
|
||||||
pb={theme.spacing(LAYOUT.MD)}
|
pb={theme.spacing(LAYOUT.MD)}
|
||||||
/>
|
/>
|
||||||
{menu.map((group) => (
|
{menu.map((item) => {
|
||||||
<Box key={group.group}>
|
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||||
<Box
|
return (
|
||||||
position="relative"
|
<NavItem
|
||||||
overflow="hidden"
|
key={item.path}
|
||||||
>
|
item={item}
|
||||||
<Typography
|
selected={selected}
|
||||||
variant="eyebrow"
|
onClick={() => handleNavClick(item.path)}
|
||||||
component="div"
|
/>
|
||||||
color={theme.palette.text.disabled}
|
);
|
||||||
fontSize={typographyLevels.s}
|
})}
|
||||||
paddingLeft={theme.spacing(5)}
|
|
||||||
paddingTop={theme.spacing(LAYOUT.SM)}
|
|
||||||
paddingBottom={theme.spacing(LAYOUT.XXS)}
|
|
||||||
whiteSpace="nowrap"
|
|
||||||
sx={{
|
|
||||||
opacity: collapsed ? 0 : 1,
|
|
||||||
transition: `opacity ${transitionTiming}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{group.group}
|
|
||||||
</Typography>
|
|
||||||
<Divider
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
opacity: collapsed ? 1 : 0,
|
|
||||||
transition: `opacity ${transitionTiming}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{group.items.map((item) => {
|
|
||||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
|
||||||
return (
|
|
||||||
<NavItem
|
|
||||||
key={item.path}
|
|
||||||
item={item}
|
|
||||||
selected={selected}
|
|
||||||
onClick={() => handleNavClick(item.path)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</List>
|
</List>
|
||||||
<StarPrompt />
|
<StarPrompt />
|
||||||
|
|
||||||
<Divider sx={{ borderColor: theme.palette.divider }} />
|
<Divider sx={{ borderColor: theme.palette.divider }} />
|
||||||
|
|
||||||
<AuthFooter
|
<AuthFooter
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|||||||
|
|
||||||
type ThemeMode = "light" | "dark";
|
type ThemeMode = "light" | "dark";
|
||||||
type ChartType = "histogram" | "line";
|
type ChartType = "histogram" | "line";
|
||||||
type TableName =
|
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
|
||||||
| "monitors"
|
|
||||||
| "team"
|
|
||||||
| "maintenance"
|
|
||||||
| "infrastructure"
|
|
||||||
| "logs"
|
|
||||||
| "pagespeed"
|
|
||||||
| "docker";
|
|
||||||
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
rowsPerPage: number;
|
rowsPerPage: number;
|
||||||
@@ -21,11 +14,9 @@ interface SidebarState {
|
|||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
monitors: TableState;
|
monitors: TableState;
|
||||||
pagespeed: TableState;
|
|
||||||
team: TableState;
|
team: TableState;
|
||||||
maintenance: TableState;
|
maintenance: TableState;
|
||||||
infrastructure: TableState;
|
infrastructure: TableState;
|
||||||
docker: TableState;
|
|
||||||
logs: TableState;
|
logs: TableState;
|
||||||
sidebar: SidebarState;
|
sidebar: SidebarState;
|
||||||
mode: ThemeMode;
|
mode: ThemeMode;
|
||||||
@@ -46,9 +37,6 @@ const initialState: UIState = {
|
|||||||
monitors: {
|
monitors: {
|
||||||
rowsPerPage: 10,
|
rowsPerPage: 10,
|
||||||
},
|
},
|
||||||
pagespeed: {
|
|
||||||
rowsPerPage: 10,
|
|
||||||
},
|
|
||||||
team: {
|
team: {
|
||||||
rowsPerPage: 5,
|
rowsPerPage: 5,
|
||||||
},
|
},
|
||||||
@@ -58,9 +46,6 @@ const initialState: UIState = {
|
|||||||
infrastructure: {
|
infrastructure: {
|
||||||
rowsPerPage: 5,
|
rowsPerPage: 5,
|
||||||
},
|
},
|
||||||
docker: {
|
|
||||||
rowsPerPage: 5,
|
|
||||||
},
|
|
||||||
logs: {
|
logs: {
|
||||||
rowsPerPage: 15,
|
rowsPerPage: 15,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
|||||||
import { useToast } from "@/Hooks/UseToast";
|
import { useToast } from "@/Hooks/UseToast";
|
||||||
import { logger } from "@/Utils/logger";
|
import { logger } from "@/Utils/logger";
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
msg: string;
|
msg: string;
|
||||||
data: T;
|
data: T;
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
import { toast, type ToastOptions } from "react-toastify";
|
import { toast, type ToastOptions } from "react-toastify";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
export const useToast = () => {
|
export const useToast = () => {
|
||||||
return useMemo(() => {
|
const showToast = (message: string, options?: ToastOptions) => {
|
||||||
const showToast = (message: string, options?: ToastOptions) => {
|
toast.dismiss();
|
||||||
toast.dismiss();
|
const baseStyle: React.CSSProperties = {
|
||||||
const baseStyle: React.CSSProperties = {
|
whiteSpace: "nowrap",
|
||||||
whiteSpace: "nowrap",
|
overflow: "hidden",
|
||||||
overflow: "hidden",
|
textOverflow: "ellipsis",
|
||||||
textOverflow: "ellipsis",
|
|
||||||
};
|
|
||||||
toast(message, {
|
|
||||||
...options,
|
|
||||||
style: { ...baseStyle, ...(options?.style || {}) },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
toast(message, {
|
||||||
|
...options,
|
||||||
|
style: { ...baseStyle, ...(options?.style || {}) },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "success" });
|
showToast(msg, { ...opts, type: "success" });
|
||||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "error" });
|
showToast(msg, { ...opts, type: "error" });
|
||||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "info" });
|
showToast(msg, { ...opts, type: "info" });
|
||||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||||
showToast(msg, { ...opts, type: "warning" });
|
showToast(msg, { ...opts, type: "warning" });
|
||||||
|
|
||||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||||
}, []);
|
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||||
import {
|
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||||
DefaultHttpMethod,
|
|
||||||
DefaultMonitorMatchMethod,
|
|
||||||
DefaultPageSpeedStrategy,
|
|
||||||
DefaultProxyMode,
|
|
||||||
} from "@/Types/Monitor";
|
|
||||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface UseMonitorFormOptions {
|
interface UseMonitorFormOptions {
|
||||||
@@ -47,10 +42,8 @@ export const getMonitorDefaults = (
|
|||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
method: data?.method ?? DefaultHttpMethod,
|
method: data?.method ?? DefaultHttpMethod,
|
||||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||||
proxyMode: data?.proxyMode ?? DefaultProxyMode,
|
|
||||||
proxyId: data?.proxyId || "",
|
|
||||||
useAdvancedMatching: data?.useAdvancedMatching || false,
|
useAdvancedMatching: data?.useAdvancedMatching || false,
|
||||||
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
|
matchMethod: data?.matchMethod || "",
|
||||||
expectedValue: data?.expectedValue || "",
|
expectedValue: data?.expectedValue || "",
|
||||||
jsonPath: data?.jsonPath || "",
|
jsonPath: data?.jsonPath || "",
|
||||||
customUpCodes: data?.customUpCodes || [],
|
customUpCodes: data?.customUpCodes || [],
|
||||||
@@ -77,7 +70,6 @@ export const getMonitorDefaults = (
|
|||||||
...base,
|
...base,
|
||||||
type: "docker",
|
type: "docker",
|
||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "game":
|
case "game":
|
||||||
@@ -145,10 +137,8 @@ export const getMonitorDefaults = (
|
|||||||
url: "",
|
url: "",
|
||||||
method: DefaultHttpMethod,
|
method: DefaultHttpMethod,
|
||||||
ignoreTlsErrors: false,
|
ignoreTlsErrors: false,
|
||||||
proxyMode: DefaultProxyMode,
|
|
||||||
proxyId: "",
|
|
||||||
useAdvancedMatching: false,
|
useAdvancedMatching: false,
|
||||||
matchMethod: DefaultMonitorMatchMethod,
|
matchMethod: "",
|
||||||
expectedValue: "",
|
expectedValue: "",
|
||||||
jsonPath: "",
|
jsonPath: "",
|
||||||
customUpCodes: [],
|
customUpCodes: [],
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
|
|
||||||
import { useDelete, useGet } from "@/Hooks/UseApi";
|
|
||||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
|
||||||
import type { RootState } from "@/store";
|
|
||||||
import {
|
|
||||||
type MonitorsWithChecksResponse,
|
|
||||||
SelectableMonitorTypes,
|
|
||||||
type Monitor,
|
|
||||||
type MonitorType,
|
|
||||||
} from "@/Types/Monitor";
|
|
||||||
import type { Tag } from "@/Types/Tag";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
|
||||||
|
|
||||||
interface MonitorListConfig {
|
|
||||||
types: MonitorType[] | "selectable";
|
|
||||||
checksLimit: number;
|
|
||||||
refreshInterval: number;
|
|
||||||
rowsPerPageTable: TableName;
|
|
||||||
rowsPerPageDefault: number;
|
|
||||||
initialSortField?: string; // default ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMonitorListController = (config: MonitorListConfig) => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const rowsPerPage = useSelector(
|
|
||||||
(state: RootState) =>
|
|
||||||
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter states
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string>("");
|
|
||||||
const [selectedState, setSelectedState] = useState<string>("");
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
const [page, setPage] = useState<number>(0);
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
|
||||||
|
|
||||||
// Convert filter selections to API filter values
|
|
||||||
// Status: pass "up"/"down" directly to the API
|
|
||||||
// State: "active" -> true, "paused" -> false
|
|
||||||
const toFilterStatus = useMemo(() => {
|
|
||||||
if (selectedStatus === "up") return "up";
|
|
||||||
if (selectedStatus === "down") return "down";
|
|
||||||
return undefined;
|
|
||||||
}, [selectedStatus]);
|
|
||||||
|
|
||||||
const toFilterActive = useMemo(() => {
|
|
||||||
if (selectedState === "active") return "true";
|
|
||||||
if (selectedState === "paused") return "false";
|
|
||||||
return undefined;
|
|
||||||
}, [selectedState]);
|
|
||||||
|
|
||||||
// Determine field and filter for the API request
|
|
||||||
// Priority: status > isActive > search > sort
|
|
||||||
const filterLookup = new Map<string | undefined, string>([
|
|
||||||
[toFilterStatus, "status"],
|
|
||||||
[toFilterActive, "isActive"],
|
|
||||||
]);
|
|
||||||
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
|
|
||||||
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
|
|
||||||
const filter = activeFilter?.[0] || debouncedSearch || undefined;
|
|
||||||
|
|
||||||
const effectiveTypes = useMemo(() => {
|
|
||||||
return config.types === "selectable"
|
|
||||||
? selectedTypes.length > 0
|
|
||||||
? selectedTypes
|
|
||||||
: [...SelectableMonitorTypes]
|
|
||||||
: config.types;
|
|
||||||
}, [config.types, selectedTypes]);
|
|
||||||
|
|
||||||
// Fetching
|
|
||||||
const monitorsWithChecksUrl = useMemo(() => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
effectiveTypes.forEach((type) => params.append("type", type));
|
|
||||||
selectedTags.forEach((tag) => params.append("tags", tag));
|
|
||||||
params.append("limit", String(config.checksLimit));
|
|
||||||
if (page !== undefined) params.append("page", String(page));
|
|
||||||
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
|
|
||||||
if (filter) params.append("filter", filter);
|
|
||||||
if (field) params.append("field", field);
|
|
||||||
if (sortOrder) params.append("order", sortOrder);
|
|
||||||
return `/monitors/team/with-checks?${params.toString()}`;
|
|
||||||
}, [
|
|
||||||
effectiveTypes,
|
|
||||||
selectedTags,
|
|
||||||
page,
|
|
||||||
rowsPerPage,
|
|
||||||
filter,
|
|
||||||
field,
|
|
||||||
sortOrder,
|
|
||||||
config.checksLimit,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: monitorsWithChecksData,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useGet<MonitorsWithChecksResponse>(
|
|
||||||
monitorsWithChecksUrl,
|
|
||||||
{},
|
|
||||||
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
|
|
||||||
);
|
|
||||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
|
||||||
|
|
||||||
const bulk = useBulkMonitorActions(refetch, page);
|
|
||||||
|
|
||||||
// Delete ops
|
|
||||||
const { deleteFn, loading: isDeleting } = useDelete();
|
|
||||||
const handleConfirmDelete = async () => {
|
|
||||||
if (!selectedMonitor) return;
|
|
||||||
await deleteFn(`/monitors/${selectedMonitor.id}`);
|
|
||||||
setSelectedMonitor(null);
|
|
||||||
refetch();
|
|
||||||
};
|
|
||||||
const handleCancelDelete = () => setSelectedMonitor(null);
|
|
||||||
|
|
||||||
// Handle filters
|
|
||||||
const handleClearFilters = useCallback(() => {
|
|
||||||
setSelectedTypes([]);
|
|
||||||
setSelectedStatus("");
|
|
||||||
setSelectedState("");
|
|
||||||
setSelectedTags([]);
|
|
||||||
setSearch("");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSetRowsPerPage = (value: number) => {
|
|
||||||
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for active filters
|
|
||||||
const hasActiveFilters = Boolean(
|
|
||||||
selectedTypes.length > 0 ||
|
|
||||||
selectedStatus ||
|
|
||||||
selectedState ||
|
|
||||||
selectedTags.length > 0 ||
|
|
||||||
search
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show empty state only when there are truly no monitors (not just filtered out)
|
|
||||||
// If filters are active and count is 0, pass 1 to prevent empty state fallback
|
|
||||||
|
|
||||||
const { monitors, summary, count } = monitorsWithChecksData ?? {
|
|
||||||
monitors: null,
|
|
||||||
summary: null,
|
|
||||||
count: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const effectiveTotalCount =
|
|
||||||
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
|
|
||||||
? 1
|
|
||||||
: (summary?.totalMonitors ?? 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Data
|
|
||||||
monitors,
|
|
||||||
summary,
|
|
||||||
count,
|
|
||||||
tags,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
effectiveTotalCount,
|
|
||||||
hasActiveFilters,
|
|
||||||
|
|
||||||
// Filter state and setters
|
|
||||||
selectedTypes,
|
|
||||||
setSelectedTypes,
|
|
||||||
selectedStatus,
|
|
||||||
setSelectedStatus,
|
|
||||||
selectedState,
|
|
||||||
setSelectedState,
|
|
||||||
selectedTags,
|
|
||||||
setSelectedTags,
|
|
||||||
search,
|
|
||||||
setSearch,
|
|
||||||
handleClearFilters,
|
|
||||||
|
|
||||||
// Paging and Sorting
|
|
||||||
page,
|
|
||||||
setPage,
|
|
||||||
rowsPerPage,
|
|
||||||
handleSetRowsPerPage,
|
|
||||||
sortField,
|
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
|
||||||
setSortOrder,
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
selectedMonitor,
|
|
||||||
setSelectedMonitor,
|
|
||||||
isDialogOpen: Boolean(selectedMonitor),
|
|
||||||
isDeleting,
|
|
||||||
handleConfirmDelete,
|
|
||||||
handleCancelDelete,
|
|
||||||
|
|
||||||
// Rendered only when config.bulkActions
|
|
||||||
...bulk,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MonitorListController = ReturnType<typeof useMonitorListController>;
|
|
||||||
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaults(data: Notification | null): NotificationFormData {
|
function buildDefaults(data: Notification | null): NotificationFormData {
|
||||||
|
if (data?.type === "matrix") {
|
||||||
|
return {
|
||||||
|
type: "matrix",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
homeserverUrl: data.homeserverUrl || "",
|
||||||
|
roomId: data.roomId || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "telegram") {
|
||||||
|
return {
|
||||||
|
type: "telegram",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "slack") {
|
||||||
|
return {
|
||||||
|
type: "slack",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "discord") {
|
||||||
|
return {
|
||||||
|
type: "discord",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "webhook") {
|
||||||
|
return {
|
||||||
|
type: "webhook",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "pager_duty") {
|
||||||
|
return {
|
||||||
|
type: "pager_duty",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "teams") {
|
||||||
|
return {
|
||||||
|
type: "teams",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "twilio") {
|
||||||
|
return {
|
||||||
|
type: "twilio",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
accountSid: data.accountSid || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
phone: data.phone || "",
|
||||||
|
twilioPhoneNumber: data.twilioPhoneNumber || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "pushover") {
|
||||||
|
return {
|
||||||
|
type: "pushover",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "ntfy") {
|
||||||
|
return {
|
||||||
|
type: "ntfy",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
topic: data.topic || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Default: email (covers both data === null and data.type === "email")
|
||||||
return {
|
return {
|
||||||
type: data?.type ?? "email",
|
type: "email",
|
||||||
notificationName: data?.notificationName || "",
|
notificationName: data?.notificationName || "",
|
||||||
address: data?.address || "",
|
address: data?.address || "",
|
||||||
accessToken: data?.accessToken || "",
|
|
||||||
accountSid: data?.accountSid || "",
|
|
||||||
phone: data?.phone || "",
|
|
||||||
twilioPhoneNumber: data?.twilioPhoneNumber || "",
|
|
||||||
homeserverUrl: data?.homeserverUrl || "",
|
|
||||||
roomId: data?.roomId || "",
|
|
||||||
topic: data?.topic || "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
|
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
|
||||||
|
|
||||||
interface UseProxiesFormOptions {
|
|
||||||
data?: ProxyResponse | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
|
|
||||||
return useMemo(() => {
|
|
||||||
const defaults: ProxyFormData = {
|
|
||||||
name: data?.name ?? "",
|
|
||||||
protocol: data?.protocol ?? "http",
|
|
||||||
host: data?.host ?? "",
|
|
||||||
port: data?.port ?? 8080,
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return { schema: proxySchema, defaults };
|
|
||||||
}, [data]);
|
|
||||||
};
|
|
||||||
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
|||||||
checkTTL: data?.checkTTL ?? 30,
|
checkTTL: data?.checkTTL ?? 30,
|
||||||
pagespeedApiKey: "",
|
pagespeedApiKey: "",
|
||||||
systemEmailPassword: "",
|
systemEmailPassword: "",
|
||||||
globalProxyEnabled: data?.globalProxyEnabled ?? false,
|
|
||||||
globalProxyId: data?.globalProxyId ?? null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { schema: settingsSchema, defaults };
|
return { schema: settingsSchema, defaults };
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ const SIDEBAR_WIDTH_VAR = 250;
|
|||||||
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
|
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
|
||||||
|
|
||||||
// Transition timing for sidebar width changes
|
// Transition timing for sidebar width changes
|
||||||
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||||
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
|
|
||||||
|
|
||||||
export const useSidebar = () => {
|
export const useSidebar = () => {
|
||||||
const collapsed = useSelector(
|
const collapsed = useSelector(
|
||||||
@@ -20,7 +19,6 @@ export const useSidebar = () => {
|
|||||||
expandedWidth: SIDEBAR_WIDTH_VAR,
|
expandedWidth: SIDEBAR_WIDTH_VAR,
|
||||||
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
||||||
transition: SIDEBAR_TRANSITION,
|
transition: SIDEBAR_TRANSITION,
|
||||||
transitionTiming: SIDEBAR_TRANSITION_TIMING,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { TextField, Button } from "@/Components/inputs";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { TextField, Button, Autocomplete } from "@/Components/inputs";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import FormHelperText from "@mui/material/FormHelperText";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { ConfigBox, BasePage } from "@/Components/design-elements";
|
import { ConfigBox, BasePage } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { UserRoles } from "@/Types/User";
|
import { UserRoles } from "@/Types/User";
|
||||||
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
|
||||||
|
|
||||||
interface RoleOption {
|
interface RoleOption {
|
||||||
id: UserRole;
|
id: UserRole;
|
||||||
@@ -40,11 +43,10 @@ const EditUserPage = () => {
|
|||||||
|
|
||||||
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
||||||
|
|
||||||
const form = useForm<EditUserFormData>({
|
const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -62,6 +64,13 @@ const EditUserPage = () => {
|
|||||||
name: t(`common.auth.roles.${role}`),
|
name: t(`common.auth.roles.${role}`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const watchedRoles = watch("role");
|
||||||
|
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
|
||||||
|
const handleRemoveRole = (roleToRemove: UserRole) => {
|
||||||
|
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
|
||||||
|
setValue("role", newRoles, { shouldValidate: true });
|
||||||
|
};
|
||||||
|
|
||||||
const canDeleteUser =
|
const canDeleteUser =
|
||||||
isAdmin &&
|
isAdmin &&
|
||||||
userId !== currentUser?.id &&
|
userId !== currentUser?.id &&
|
||||||
@@ -81,31 +90,42 @@ const EditUserPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BasePage loading={isLoading}>
|
||||||
<BasePage
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
loading={isLoading}
|
|
||||||
component={"form"}
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
>
|
|
||||||
<Stack gap={theme.spacing(8)}>
|
<Stack gap={theme.spacing(8)}>
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.account.form.name.title")}
|
title={t("pages.account.form.name.title")}
|
||||||
subtitle={t("pages.account.form.name.description")}
|
subtitle={t("pages.account.form.name.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<Stack gap={theme.spacing(8)}>
|
<Stack gap={theme.spacing(8)}>
|
||||||
<FormTextField
|
<Controller
|
||||||
name="firstName"
|
name="firstName"
|
||||||
autoComplete="given-name"
|
control={control}
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
render={({ field, fieldState }) => (
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<FormTextField
|
<Controller
|
||||||
name="lastName"
|
name="lastName"
|
||||||
autoComplete="family-name"
|
control={control}
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
render={({ field, fieldState }) => (
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
@@ -119,11 +139,55 @@ const EditUserPage = () => {
|
|||||||
title={t("pages.editUser.form.roles.title")}
|
title={t("pages.editUser.form.roles.title")}
|
||||||
subtitle={t("pages.editUser.form.roles.description")}
|
subtitle={t("pages.editUser.form.roles.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<FormMultiSelectField
|
<Stack spacing={theme.spacing(4)}>
|
||||||
name="role"
|
<Controller
|
||||||
fieldLabel={t("common.form.role.option.role.label")}
|
name="role"
|
||||||
options={roleOptions}
|
control={control}
|
||||||
/>
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<Autocomplete
|
||||||
|
fieldLabel={t("common.form.role.option.role.label")}
|
||||||
|
multiple
|
||||||
|
options={roleOptions}
|
||||||
|
value={selectedRoles}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
onChange={(_: unknown, newValue: RoleOption[]) => {
|
||||||
|
field.onChange(newValue.map((r) => r.id));
|
||||||
|
}}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
/>
|
||||||
|
{fieldState.error && (
|
||||||
|
<FormHelperText error>{fieldState.error.message}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{selectedRoles.length > 0 && (
|
||||||
|
<Stack
|
||||||
|
flex={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{selectedRoles.map((role, index) => (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
key={role.id}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Typography flexGrow={1}>{role.name}</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemoveRole(role.id)}
|
||||||
|
aria-label="Remove role"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
{index < selectedRoles.length - 1 && <Divider />}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Stack
|
<Stack
|
||||||
@@ -153,19 +217,19 @@ const EditUserPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
<DialogInput
|
</form>
|
||||||
open={showDeleteDialog}
|
<DialogInput
|
||||||
title={t("pages.editUser.dialog.removeUser.title")}
|
open={showDeleteDialog}
|
||||||
content={t("pages.editUser.dialog.removeUser.content", {
|
title={t("pages.editUser.dialog.removeUser.title")}
|
||||||
name: `${user?.firstName} ${user?.lastName}`,
|
content={t("pages.editUser.dialog.removeUser.content", {
|
||||||
})}
|
name: `${user?.firstName} ${user?.lastName}`,
|
||||||
onCancel={() => setShowDeleteDialog(false)}
|
})}
|
||||||
onConfirm={handleDeleteUser}
|
onCancel={() => setShowDeleteDialog(false)}
|
||||||
confirmText={t("common.buttons.removeUser")}
|
onConfirm={handleDeleteUser}
|
||||||
loading={isDeleting}
|
confirmText={t("common.buttons.removeUser")}
|
||||||
/>
|
loading={isDeleting}
|
||||||
</BasePage>
|
/>
|
||||||
</FormProvider>
|
</BasePage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { ConfigBox } from "@/Components/design-elements";
|
import { ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { TextField, Button } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { usePasswordForm } from "@/Hooks/usePasswordForm";
|
import { usePasswordForm } from "@/Hooks/usePasswordForm";
|
||||||
import { usePatch } from "@/Hooks/UseApi";
|
import { usePatch } from "@/Hooks/UseApi";
|
||||||
import type { PasswordFormData } from "@/Validation/password";
|
import type { PasswordFormData } from "@/Validation/password";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export const TabPassword = () => {
|
export const TabPassword = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -17,11 +15,10 @@ export const TabPassword = () => {
|
|||||||
const { resolver, defaults } = usePasswordForm();
|
const { resolver, defaults } = usePasswordForm();
|
||||||
const { patch, loading } = usePatch<FormData, void>();
|
const { patch, loading } = usePatch<FormData, void>();
|
||||||
|
|
||||||
const form = useForm<PasswordFormData>({
|
const { control, handleSubmit, reset } = useForm<PasswordFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
|
||||||
|
|
||||||
const onSubmit = async (data: PasswordFormData) => {
|
const onSubmit = async (data: PasswordFormData) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -35,61 +32,85 @@ export const TabPassword = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Stack
|
||||||
<Stack
|
gap={theme.spacing(8)}
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
>
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.account.form.currentPassword.title")}
|
||||||
title={t("pages.account.form.currentPassword.title")}
|
subtitle={t("pages.account.form.currentPassword.description")}
|
||||||
subtitle={t("pages.account.form.currentPassword.description")}
|
rightContent={
|
||||||
rightContent={
|
<Controller
|
||||||
<FormTextField
|
name="currentPassword"
|
||||||
name="currentPassword"
|
control={control}
|
||||||
type="password"
|
render={({ field, fieldState }) => (
|
||||||
autoComplete="current-password"
|
<TextField
|
||||||
fieldLabel={t("pages.account.form.currentPassword.option.label")}
|
{...field}
|
||||||
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
|
type="password"
|
||||||
|
fieldLabel={t("pages.account.form.currentPassword.option.label")}
|
||||||
|
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
|
||||||
|
autoComplete="current-password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.account.form.newPassword.title")}
|
||||||
|
subtitle={t("pages.account.form.newPassword.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack gap={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="newPassword"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.account.form.newPassword.option.newPassword.label"
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.account.form.newPassword.option.newPassword.placeholder"
|
||||||
|
)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
<Controller
|
||||||
/>
|
name="confirm"
|
||||||
<ConfigBox
|
control={control}
|
||||||
title={t("pages.account.form.newPassword.title")}
|
render={({ field, fieldState }) => (
|
||||||
subtitle={t("pages.account.form.newPassword.description")}
|
<TextField
|
||||||
rightContent={
|
{...field}
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
type="password"
|
||||||
<FormTextField
|
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
|
||||||
name="newPassword"
|
placeholder={t(
|
||||||
type="password"
|
"pages.account.form.newPassword.option.confirm.placeholder"
|
||||||
autoComplete="new-password"
|
)}
|
||||||
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")}
|
autoComplete="new-password"
|
||||||
placeholder={t(
|
error={!!fieldState.error}
|
||||||
"pages.account.form.newPassword.option.newPassword.placeholder"
|
helperText={fieldState.error?.message ?? ""}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
<FormTextField
|
/>
|
||||||
name="confirm"
|
</Stack>
|
||||||
type="password"
|
}
|
||||||
autoComplete="new-password"
|
/>
|
||||||
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
|
<Button
|
||||||
placeholder={t(
|
type="submit"
|
||||||
"pages.account.form.newPassword.option.confirm.placeholder"
|
variant="contained"
|
||||||
)}
|
color="primary"
|
||||||
/>
|
loading={loading}
|
||||||
</Stack>
|
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||||
}
|
>
|
||||||
/>
|
{t("common.buttons.save")}
|
||||||
<Button
|
</Button>
|
||||||
type="submit"
|
</Stack>
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
loading={loading}
|
|
||||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { ConfigBox } from "@/Components/design-elements";
|
import { ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { TextField, Button } from "@/Components/inputs";
|
||||||
import { ImageUpload } from "@/Components/inputs";
|
import { ImageUpload } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { useProfileForm } from "@/Hooks/useProfileForm";
|
import { useProfileForm } from "@/Hooks/useProfileForm";
|
||||||
import { usePatch } from "@/Hooks/UseApi";
|
import { usePatch } from "@/Hooks/UseApi";
|
||||||
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
|
|||||||
import type { ProfileFormData } from "@/Validation/profile";
|
import type { ProfileFormData } from "@/Validation/profile";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import type { User } from "@/Types/User";
|
import type { User } from "@/Types/User";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export const TabProfile = () => {
|
export const TabProfile = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -24,7 +22,7 @@ export const TabProfile = () => {
|
|||||||
const { resolver, defaults } = useProfileForm();
|
const { resolver, defaults } = useProfileForm();
|
||||||
const { patch, loading: patchLoading } = usePatch<FormData, User>();
|
const { patch, loading: patchLoading } = usePatch<FormData, User>();
|
||||||
|
|
||||||
const form = useForm<ProfileFormData>({
|
const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
firstName: user?.firstName ?? defaults.firstName,
|
firstName: user?.firstName ?? defaults.firstName,
|
||||||
@@ -33,7 +31,7 @@ export const TabProfile = () => {
|
|||||||
deleteProfileImage: defaults.deleteProfileImage,
|
deleteProfileImage: defaults.deleteProfileImage,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { handleSubmit, setValue, watch } = form;
|
|
||||||
const currentImage = watch("profileImage");
|
const currentImage = watch("profileImage");
|
||||||
const deleteImage = watch("deleteProfileImage");
|
const deleteImage = watch("deleteProfileImage");
|
||||||
|
|
||||||
@@ -76,52 +74,66 @@ export const TabProfile = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Stack
|
||||||
<Stack
|
gap={theme.spacing(8)}
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
>
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.account.form.name.title")}
|
||||||
title={t("pages.account.form.name.title")}
|
subtitle={t("pages.account.form.name.description")}
|
||||||
subtitle={t("pages.account.form.name.description")}
|
rightContent={
|
||||||
rightContent={
|
<Stack gap={theme.spacing(8)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
<Controller
|
||||||
<FormTextField
|
name="firstName"
|
||||||
name="firstName"
|
control={control}
|
||||||
autoComplete="given-name"
|
render={({ field, fieldState }) => (
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
<TextField
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
{...field}
|
||||||
/>
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
<FormTextField
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
name="lastName"
|
autoComplete="given-name"
|
||||||
autoComplete="family-name"
|
error={!!fieldState.error}
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
/>
|
||||||
/>
|
)}
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.account.form.photo.title")}
|
|
||||||
subtitle={t("pages.account.form.photo.description")}
|
|
||||||
rightContent={
|
|
||||||
<ImageUpload
|
|
||||||
src={getCurrentImageSrc()}
|
|
||||||
onChange={handleImageChange}
|
|
||||||
/>
|
/>
|
||||||
}
|
<Controller
|
||||||
/>
|
name="lastName"
|
||||||
<Button
|
control={control}
|
||||||
type="submit"
|
render={({ field, fieldState }) => (
|
||||||
variant="contained"
|
<TextField
|
||||||
color="primary"
|
{...field}
|
||||||
loading={patchLoading}
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
>
|
autoComplete="family-name"
|
||||||
{t("common.buttons.save")}
|
error={!!fieldState.error}
|
||||||
</Button>
|
helperText={fieldState.error?.message ?? ""}
|
||||||
</Stack>
|
/>
|
||||||
</FormProvider>
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.account.form.photo.title")}
|
||||||
|
subtitle={t("pages.account.form.photo.description")}
|
||||||
|
rightContent={
|
||||||
|
<ImageUpload
|
||||||
|
src={getCurrentImageSrc()}
|
||||||
|
onChange={handleImageChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
loading={patchLoading}
|
||||||
|
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||||
|
>
|
||||||
|
{t("common.buttons.save")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { Dialog } from "@/Components/inputs";
|
import { Dialog, TextField, Select } from "@/Components/inputs";
|
||||||
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
|
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
|
||||||
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
|
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
|
||||||
import type { User } from "@/Types/User";
|
import type { UserRole, User } from "@/Types/User";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { RoleSelectField } from "./RoleSelectField";
|
|
||||||
|
|
||||||
interface AddTeamMemberDialogProps {
|
interface AddTeamMemberDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
|
|||||||
const { schema, defaults } = useAddTeamMemberForm();
|
const { schema, defaults } = useAddTeamMemberForm();
|
||||||
const { post, loading } = usePost<RegisterPayload, User>();
|
const { post, loading } = usePost<RegisterPayload, User>();
|
||||||
|
|
||||||
const form = useForm<AddTeamMemberFormData>({
|
const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
values: defaults,
|
values: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, reset, setError } = form;
|
const roleOptions: { value: UserRole; label: string }[] = [
|
||||||
|
{ value: "admin", label: t("common.auth.roles.admin") },
|
||||||
|
{ value: "user", label: t("common.auth.roles.user") },
|
||||||
|
{ value: "demo", label: t("common.auth.roles.demo") },
|
||||||
|
];
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
reset();
|
reset();
|
||||||
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
|
|||||||
const onSubmit = async (data: AddTeamMemberFormData) => {
|
const onSubmit = async (data: AddTeamMemberFormData) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
const { confirm: _confirm, ...userData } = data;
|
const { confirm, ...userData } = data;
|
||||||
const payload: RegisterPayload = userData;
|
const payload: RegisterPayload = userData;
|
||||||
|
|
||||||
const result = await post("/auth/users", payload);
|
const result = await post("/auth/users", payload);
|
||||||
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Dialog
|
||||||
<Dialog
|
open={open}
|
||||||
open={open}
|
title={t("pages.account.team.addMember.title")}
|
||||||
title={t("pages.account.team.addMember.title")}
|
content={t("pages.account.team.addMember.description")}
|
||||||
content={t("pages.account.team.addMember.description")}
|
onCancel={handleClose}
|
||||||
onCancel={handleClose}
|
onConfirm={handleSubmit(onSubmit)}
|
||||||
onConfirm={handleSubmit(onSubmit)}
|
confirmText={t("common.buttons.addMember")}
|
||||||
confirmText={t("common.buttons.addMember")}
|
loading={loading}
|
||||||
loading={loading}
|
maxWidth="sm"
|
||||||
maxWidth="sm"
|
fullWidth
|
||||||
fullWidth
|
>
|
||||||
>
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
<Stack
|
||||||
<Stack
|
direction="row"
|
||||||
direction="row"
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
>
|
||||||
>
|
<Controller
|
||||||
<FormTextField
|
name="firstName"
|
||||||
name="firstName"
|
control={control}
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
render={({ field, fieldState }) => (
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
<TextField
|
||||||
/>
|
{...field}
|
||||||
<FormTextField
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
name="lastName"
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
error={!!fieldState.error}
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
/>
|
sx={{ flex: 1 }}
|
||||||
</Stack>
|
/>
|
||||||
<FormTextField
|
)}
|
||||||
name="email"
|
/>
|
||||||
type="email"
|
<Controller
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
name="lastName"
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoleSelectField />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<FormTextField
|
|
||||||
name="password"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
type="password"
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="confirm"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Dialog>
|
<Controller
|
||||||
</FormProvider>
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
|
type="email"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value[0] ?? "user"}
|
||||||
|
onChange={(e) => field.onChange([e.target.value])}
|
||||||
|
fieldLabel={t("common.form.role.option.role.label")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{roleOptions.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
type="password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirm"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
type="password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -59,12 +59,7 @@ export const HeaderTeamControls = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={
|
startIcon={<Icon icon={Mail} />}
|
||||||
<Icon
|
|
||||||
icon={Mail}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={onInviteClick}
|
onClick={onInviteClick}
|
||||||
>
|
>
|
||||||
{t("common.buttons.inviteMember")}
|
{t("common.buttons.inviteMember")}
|
||||||
@@ -74,12 +69,7 @@ export const HeaderTeamControls = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={
|
startIcon={<Icon icon={UserPlus} />}
|
||||||
<Icon
|
|
||||||
icon={UserPlus}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={onAddMemberClick}
|
onClick={onAddMemberClick}
|
||||||
>
|
>
|
||||||
{t("common.buttons.addMember")}
|
{t("common.buttons.addMember")}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { useTheme, Typography } from "@mui/material";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { useTheme, FormHelperText, Typography } from "@mui/material";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { Dialog, TextField, Button } from "@/Components/inputs";
|
import { Dialog, TextField, Select, Button } from "@/Components/inputs";
|
||||||
|
import type { UserRole } from "@/Types/User";
|
||||||
import { useInviteForm } from "@/Hooks/useInviteForm";
|
import { useInviteForm } from "@/Hooks/useInviteForm";
|
||||||
import type { InviteFormData } from "@/Validation/invite";
|
import type { InviteFormData } from "@/Validation/invite";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { runtimeConfig } from "@/Utils/runtimeConfig";
|
import { runtimeConfig } from "@/Utils/runtimeConfig";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { RoleSelectField } from "./RoleSelectField";
|
|
||||||
|
|
||||||
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
|
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
|
||||||
|
|
||||||
@@ -31,12 +31,11 @@ export const InviteTeamMemberDialog = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolver, defaults } = useInviteForm();
|
const { resolver, defaults } = useInviteForm();
|
||||||
|
|
||||||
const form = useForm<InviteFormData>({
|
const { control, handleSubmit, reset } = useForm<InviteFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
values: defaults,
|
values: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
|
||||||
|
|
||||||
const { post: generateToken, loading: generateLoading } = usePost<
|
const { post: generateToken, loading: generateLoading } = usePost<
|
||||||
InviteFormData,
|
InviteFormData,
|
||||||
@@ -48,6 +47,12 @@ export const InviteTeamMemberDialog = ({
|
|||||||
>();
|
>();
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const roleOptions: { value: UserRole; label: string }[] = [
|
||||||
|
{ value: "admin", label: t("common.auth.roles.admin") },
|
||||||
|
{ value: "user", label: t("common.auth.roles.user") },
|
||||||
|
{ value: "demo", label: t("common.auth.roles.demo") },
|
||||||
|
];
|
||||||
|
|
||||||
const handleGenerateToken = async (data: InviteFormData) => {
|
const handleGenerateToken = async (data: InviteFormData) => {
|
||||||
const result = await generateToken("/invite", data);
|
const result = await generateToken("/invite", data);
|
||||||
if (result?.data?.token) {
|
if (result?.data?.token) {
|
||||||
@@ -71,55 +76,88 @@ export const InviteTeamMemberDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Dialog
|
||||||
<Dialog
|
open={open}
|
||||||
open={open}
|
title={t("pages.account.team.invite.title")}
|
||||||
title={t("pages.account.team.invite.title")}
|
content={t("pages.account.team.invite.description")}
|
||||||
content={t("pages.account.team.invite.description")}
|
onCancel={handleClose}
|
||||||
onCancel={handleClose}
|
onConfirm={handleSubmit(handleSendInvite)}
|
||||||
onConfirm={handleSubmit(handleSendInvite)}
|
confirmText={t("common.buttons.sendInvite")}
|
||||||
confirmText={t("common.buttons.sendInvite")}
|
loading={sendLoading || generateLoading}
|
||||||
loading={sendLoading || generateLoading}
|
maxWidth="sm"
|
||||||
maxWidth="sm"
|
fullWidth
|
||||||
fullWidth
|
additionalButtons={
|
||||||
additionalButtons={
|
<Button
|
||||||
<Button
|
variant="contained"
|
||||||
variant="contained"
|
color="primary"
|
||||||
color="primary"
|
onClick={handleSubmit(handleGenerateToken)}
|
||||||
onClick={handleSubmit(handleGenerateToken)}
|
loading={generateLoading || sendLoading}
|
||||||
loading={generateLoading || sendLoading}
|
>
|
||||||
>
|
{t("common.buttons.generateToken")}
|
||||||
{t("common.buttons.generateToken")}
|
</Button>
|
||||||
</Button>
|
}
|
||||||
}
|
>
|
||||||
>
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
<Controller
|
||||||
<FormTextField
|
name="email"
|
||||||
name="email"
|
control={control}
|
||||||
type="email"
|
render={({ field, fieldState }) => (
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
<TextField
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
{...field}
|
||||||
/>
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
<RoleSelectField />
|
type="email"
|
||||||
{inviteLink && (
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange, ...field }, fieldState }) => (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2">
|
<Select
|
||||||
{t("pages.account.team.invite.linkLabel")}
|
{...field}
|
||||||
</Typography>
|
value={Array.isArray(value) ? (value[0] ?? "") : ""}
|
||||||
<TextField
|
onChange={(e) => onChange([e.target.value as UserRole])}
|
||||||
value={inviteLink}
|
fieldLabel={t("common.form.role.option.role.label")}
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
error={!!fieldState.error}
|
||||||
input: {
|
>
|
||||||
readOnly: true,
|
{roleOptions.map((option) => (
|
||||||
},
|
<MenuItem
|
||||||
}}
|
key={option.value}
|
||||||
/>
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{fieldState.error && (
|
||||||
|
<FormHelperText error>{fieldState.error.message}</FormHelperText>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
/>
|
||||||
</Dialog>
|
{inviteLink && (
|
||||||
</FormProvider>
|
<>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{t("pages.account.team.invite.linkLabel")}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={inviteLink}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import FormHelperText from "@mui/material/FormHelperText";
|
|
||||||
import { useController } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Select } from "@/Components/inputs";
|
|
||||||
import { UserRoles } from "@/Types/User";
|
|
||||||
import type { UserRole } from "@/Types/User";
|
|
||||||
|
|
||||||
export const RoleSelectField = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
|
|
||||||
name: "role",
|
|
||||||
});
|
|
||||||
|
|
||||||
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
|
|
||||||
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
|
|
||||||
value: role,
|
|
||||||
label: t(`common.auth.roles.${role}`),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value[0] ?? "user"}
|
|
||||||
onChange={(e) => field.onChange([e.target.value])}
|
|
||||||
fieldLabel={t("common.form.role.option.role.label")}
|
|
||||||
fullWidth
|
|
||||||
error={!!fieldState.error}
|
|
||||||
>
|
|
||||||
{roleOptions.map((option) => (
|
|
||||||
<MenuItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{fieldState.error && (
|
|
||||||
<FormHelperText error>{fieldState.error.message}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useLoginForm } from "@/Hooks/useLoginForm";
|
import { useLoginForm } from "@/Hooks/useLoginForm";
|
||||||
import type { LoginFormData } from "@/Validation/login";
|
import type { LoginFormData } from "@/Validation/login";
|
||||||
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { setAuthState } from "@/Features/Auth/authSlice";
|
import { setAuthState } from "@/Features/Auth/authSlice";
|
||||||
import { useLazyGet, usePost } from "@/Hooks/UseApi";
|
import { useLazyGet, usePost } from "@/Hooks/UseApi";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,13 +31,11 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
const { schema, defaults } = useLoginForm();
|
const { schema, defaults } = useLoginForm();
|
||||||
|
|
||||||
const form = useForm<LoginFormData>({
|
const { control, handleSubmit } = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit } = form;
|
|
||||||
|
|
||||||
if (isCheckingAdmin) return null;
|
if (isCheckingAdmin) return null;
|
||||||
|
|
||||||
const onSubmit = async (data: LoginFormData) => {
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
@@ -53,39 +50,53 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.login.title")}
|
||||||
title={t("pages.auth.login.title")}
|
subtitle={t("pages.auth.login.subtitle")}
|
||||||
subtitle={t("pages.auth.login.subtitle")}
|
>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<FormTextField
|
{t("pages.auth.login.submit")}
|
||||||
name="email"
|
</Button>
|
||||||
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
<TextLink
|
||||||
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
alignSelf={"center"}
|
||||||
/>
|
text={t("pages.auth.login.links.forgotPassword.text")}
|
||||||
<FormTextField
|
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
|
||||||
name="password"
|
href="/forgot-password"
|
||||||
type="password"
|
/>
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
</BaseAuthPage>
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t("pages.auth.login.submit")}
|
|
||||||
</Button>
|
|
||||||
<TextLink
|
|
||||||
alignSelf={"center"}
|
|
||||||
text={t("pages.auth.login.links.forgotPassword.text")}
|
|
||||||
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
|
|
||||||
href="/forgot-password"
|
|
||||||
/>
|
|
||||||
</BaseAuthPage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
|
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
|
||||||
import type { RecoveryFormData } from "@/Validation/recovery";
|
import type { RecoveryFormData } from "@/Validation/recovery";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
const ForgotPasswordPage = () => {
|
const ForgotPasswordPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
|
|||||||
|
|
||||||
const { schema, defaults } = useRecoveryForm();
|
const { schema, defaults } = useRecoveryForm();
|
||||||
|
|
||||||
const form = useForm<RecoveryFormData>({
|
const { control, handleSubmit } = useForm<RecoveryFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit } = form;
|
|
||||||
|
|
||||||
const onSubmit = async (data: RecoveryFormData) => {
|
const onSubmit = async (data: RecoveryFormData) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.forgotPassword.title")}
|
||||||
title={t("pages.auth.forgotPassword.title")}
|
subtitle={t("pages.auth.forgotPassword.subtitle")}
|
||||||
subtitle={t("pages.auth.forgotPassword.subtitle")}
|
>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<FormTextField
|
{t("pages.auth.forgotPassword.submit")}
|
||||||
name="email"
|
</Button>
|
||||||
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
<TextLink
|
||||||
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
alignSelf={"center"}
|
||||||
/>
|
text={t("pages.auth.forgotPassword.links.login.text")}
|
||||||
<Button
|
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
||||||
variant="contained"
|
href="/login"
|
||||||
type="submit"
|
/>
|
||||||
loading={loading}
|
</BaseAuthPage>
|
||||||
>
|
|
||||||
{t("pages.auth.forgotPassword.submit")}
|
|
||||||
</Button>
|
|
||||||
<TextLink
|
|
||||||
alignSelf={"center"}
|
|
||||||
text={t("pages.auth.forgotPassword.links.login.text")}
|
|
||||||
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
|
||||||
href="/login"
|
|
||||||
/>
|
|
||||||
</BaseAuthPage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseAuthPage } from "@/Components/design-elements";
|
import { BaseAuthPage } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useRegisterForm } from "@/Hooks/useRegisterForm";
|
import { useRegisterForm } from "@/Hooks/useRegisterForm";
|
||||||
import type { RegisterFormData } from "@/Validation/register";
|
import type { RegisterFormData } from "@/Validation/register";
|
||||||
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import type { AuthResponse } from "@/Types/User";
|
import type { AuthResponse } from "@/Types/User";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
interface RegisterPayload {
|
interface RegisterPayload {
|
||||||
user: Omit<RegisterFormData, "confirm">;
|
user: Omit<RegisterFormData, "confirm">;
|
||||||
@@ -46,11 +45,10 @@ const RegisterPage = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const form = useForm<RegisterFormData>({
|
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, setError, reset } = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || hasVerified.current) return;
|
if (!token || hasVerified.current) return;
|
||||||
@@ -66,14 +64,14 @@ const RegisterPage = () => {
|
|||||||
navigate("/register", { replace: true });
|
navigate("/register", { replace: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [token, defaults, navigate, reset, verifyToken]);
|
}, [token]);
|
||||||
|
|
||||||
if (isCheckingAdmin) return null;
|
if (isCheckingAdmin) return null;
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterFormData) => {
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
const { confirm: _confirm, ...userData } = data;
|
const { confirm, ...userData } = data;
|
||||||
const payload: RegisterPayload = { user: userData };
|
const payload: RegisterPayload = { user: userData };
|
||||||
if (token) {
|
if (token) {
|
||||||
payload.token = token;
|
payload.token = token;
|
||||||
@@ -91,70 +89,105 @@ const RegisterPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.register.title")}
|
||||||
title={t("pages.auth.register.title")}
|
subtitle={t("pages.auth.register.subtitle")}
|
||||||
subtitle={t("pages.auth.register.subtitle")}
|
>
|
||||||
>
|
{!token && (
|
||||||
{!token && (
|
<Alert
|
||||||
<Alert
|
severity="info"
|
||||||
severity="info"
|
icon={false}
|
||||||
icon={false}
|
sx={(theme) => ({
|
||||||
sx={(theme) => ({
|
fontSize: 13,
|
||||||
fontSize: 13,
|
lineHeight: 1.55,
|
||||||
lineHeight: 1.55,
|
color: theme.palette.text.secondary,
|
||||||
color: theme.palette.text.secondary,
|
backgroundColor: theme.palette.action.hover,
|
||||||
backgroundColor: theme.palette.action.hover,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
borderRadius: 1,
|
||||||
borderRadius: 1,
|
"& .MuiAlert-message": { padding: 0 },
|
||||||
"& .MuiAlert-message": { padding: 0 },
|
})}
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t("pages.auth.register.setupNotice")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<FormTextField
|
|
||||||
name="firstName"
|
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="lastName"
|
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="email"
|
|
||||||
disabled={!!token}
|
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="confirm"
|
|
||||||
type="password"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
>
|
>
|
||||||
{t("pages.auth.register.submit")}
|
{t("pages.auth.register.setupNotice")}
|
||||||
</Button>
|
</Alert>
|
||||||
</BaseAuthPage>
|
)}
|
||||||
</FormProvider>
|
<Controller
|
||||||
|
name="firstName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="lastName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
disabled={!!token}
|
||||||
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirm"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t("pages.auth.register.submit")}
|
||||||
|
</Button>
|
||||||
|
</BaseAuthPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
|
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
|
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
|
||||||
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
|
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
|
||||||
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { setAuthState } from "@/Features/Auth/authSlice";
|
import { setAuthState } from "@/Features/Auth/authSlice";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
const SetNewPasswordPage = () => {
|
const SetNewPasswordPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
|
|||||||
|
|
||||||
const { schema, defaults } = useSetNewPasswordForm();
|
const { schema, defaults } = useSetNewPasswordForm();
|
||||||
|
|
||||||
const form = useForm<SetNewPasswordFormData>({
|
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, watch } = form;
|
|
||||||
const password = watch("password");
|
const password = watch("password");
|
||||||
const confirm = watch("confirm");
|
const confirm = watch("confirm");
|
||||||
|
|
||||||
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.setNewPassword.title")}
|
||||||
title={t("pages.auth.setNewPassword.title")}
|
subtitle={t("pages.auth.setNewPassword.subtitle")}
|
||||||
subtitle={t("pages.auth.setNewPassword.subtitle")}
|
>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirm"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stack gap={theme.spacing(4)}>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.length")}
|
||||||
|
variant={getVariant(hasLength)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.special")}
|
||||||
|
variant={getVariant(hasSpecial)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.number")}
|
||||||
|
variant={getVariant(hasNumber)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.uppercase")}
|
||||||
|
variant={getVariant(hasUppercase)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.lowercase")}
|
||||||
|
variant={getVariant(hasLowercase)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.match")}
|
||||||
|
variant={getVariant(passwordsMatch)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<FormTextField
|
{t("common.buttons.resetPassword")}
|
||||||
name="password"
|
</Button>
|
||||||
type="password"
|
<TextLink
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
alignSelf="center"
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
text={t("pages.auth.forgotPassword.links.login.text")}
|
||||||
/>
|
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
||||||
<FormTextField
|
href="/login"
|
||||||
name="confirm"
|
/>
|
||||||
type="password"
|
</BaseAuthPage>
|
||||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack gap={theme.spacing(4)}>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.length")}
|
|
||||||
variant={getVariant(hasLength)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.special")}
|
|
||||||
variant={getVariant(hasSpecial)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.number")}
|
|
||||||
variant={getVariant(hasNumber)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.uppercase")}
|
|
||||||
variant={getVariant(hasUppercase)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.lowercase")}
|
|
||||||
variant={getVariant(hasLowercase)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.match")}
|
|
||||||
variant={getVariant(passwordsMatch)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t("common.buttons.resetPassword")}
|
|
||||||
</Button>
|
|
||||||
<TextLink
|
|
||||||
alignSelf="center"
|
|
||||||
text={t("pages.auth.forgotPassword.links.login.text")}
|
|
||||||
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
|
||||||
href="/login"
|
|
||||||
/>
|
|
||||||
</BaseAuthPage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import { useParams } from "react-router-dom";
|
|||||||
import { useGet } from "@/Hooks/UseApi";
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
||||||
import type { DateRange } from "@/Types/Query";
|
|
||||||
|
|
||||||
const Checks = () => {
|
const Checks = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { monitorId } = useParams<{ monitorId?: string }>();
|
const { monitorId } = useParams<{ monitorId?: string }>();
|
||||||
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
const [dateRange, setDateRange] = useState<string>("recent");
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("down");
|
const [statusFilter, setStatusFilter] = useState<string>("down");
|
||||||
const [page, setPage] = useState<number>(0);
|
const [page, setPage] = useState<number>(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,78 +0,0 @@
|
|||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
|
|
||||||
import { layoutCards } from "../layout";
|
|
||||||
import { useCardTransitions } from "../useCardTransitions";
|
|
||||||
import { CardReorderControls } from "./CardReorderControls";
|
|
||||||
import { CardSlotContext } from "./CardSlotContext";
|
|
||||||
|
|
||||||
export const CardGrid = ({
|
|
||||||
cards,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
cards: CardDefinition[];
|
|
||||||
onMove: (id: CardId, delta: -1 | 1) => void;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const placed = useMemo(() => layoutCards(cards), [cards]);
|
|
||||||
const { registerCard, capturePositions } = useCardTransitions();
|
|
||||||
|
|
||||||
// Positions are measured before the state change so the cards can be
|
|
||||||
// animated from where they were to where they land.
|
|
||||||
const handleMove = (id: CardId, delta: -1 | 1) => {
|
|
||||||
capturePositions(id);
|
|
||||||
onMove(id, delta);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
display="grid"
|
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
|
||||||
sx={{
|
|
||||||
gridTemplateColumns: {
|
|
||||||
xs: "1fr",
|
|
||||||
md: `repeat(${GRID_COLUMNS}, 1fr)`,
|
|
||||||
},
|
|
||||||
alignItems: "stretch",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{placed.map(({ card, renderedWidth }, index) => {
|
|
||||||
const Component = card.component;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={card.id}
|
|
||||||
ref={(node: HTMLElement | null) => registerCard(card.id, node)}
|
|
||||||
minWidth={0}
|
|
||||||
// Positioned so the moving card can be lifted above its
|
|
||||||
// neighbours with a z-index while it travels.
|
|
||||||
position="relative"
|
|
||||||
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
|
|
||||||
// Hints the compositor before a transform is applied, so the
|
|
||||||
// first frame of a move does not stutter.
|
|
||||||
sx={{ willChange: "transform" }}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* The controls go through context rather than a prop so the 17
|
|
||||||
* card components stay unaware of their own position.
|
|
||||||
*/}
|
|
||||||
<CardSlotContext.Provider
|
|
||||||
value={
|
|
||||||
<CardReorderControls
|
|
||||||
canMoveUp={index > 0}
|
|
||||||
canMoveDown={index < placed.length - 1}
|
|
||||||
onMoveUp={() => handleMove(card.id, -1)}
|
|
||||||
onMoveDown={() => handleMove(card.id, 1)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Component />
|
|
||||||
</CardSlotContext.Provider>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { Dialog, Checkbox } from "@/Components/inputs";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
|
|
||||||
|
|
||||||
interface CardPickerProps {
|
|
||||||
open: boolean;
|
|
||||||
available: CardDefinition[];
|
|
||||||
selected: CardId[];
|
|
||||||
onAdd: (id: CardId) => void;
|
|
||||||
onRemove: (id: CardId) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cards are grouped by sidebar section, not by load cost: you look for a card
|
|
||||||
* where you look for the feature it belongs to. Admin-only cards never reach
|
|
||||||
* `available` for a non-admin, so nobody is offered a card they cannot load.
|
|
||||||
*/
|
|
||||||
export const CardPicker = ({
|
|
||||||
open,
|
|
||||||
available,
|
|
||||||
selected,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
onReset,
|
|
||||||
onClose,
|
|
||||||
}: CardPickerProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
|
||||||
return CardGroups.map((group: CardGroup) => ({
|
|
||||||
group,
|
|
||||||
cards: available.filter((card) => card.group === group),
|
|
||||||
})).filter((entry) => entry.cards.length > 0);
|
|
||||||
}, [available]);
|
|
||||||
|
|
||||||
const chosen = new Set(selected);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
title={t("pages.dashboard.picker.title")}
|
|
||||||
// The dialog lays its actions out as cancel → additional → confirm, all
|
|
||||||
// right-aligned. Reset takes the cancel slot with an auto right margin
|
|
||||||
// so it sits alone on the left, away from Done — a destructive action
|
|
||||||
// should not be adjacent to the one people reach for by reflex.
|
|
||||||
onCancel={onReset}
|
|
||||||
cancelText={t("pages.dashboard.picker.reset")}
|
|
||||||
cancelSx={{ marginRight: "auto" }}
|
|
||||||
// Escape must dismiss, not reset. The dialog sends Escape to onCancel by
|
|
||||||
// default, and this one's cancel slot resets the selection — so without
|
|
||||||
// this, Escape would wipe the user's cards instead of closing.
|
|
||||||
onEscape={onClose}
|
|
||||||
// Changes apply immediately, so Done only dismisses; it is the primary
|
|
||||||
// action here and carries the usual contained styling.
|
|
||||||
onConfirm={onClose}
|
|
||||||
confirmText={t("pages.dashboard.picker.done")}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* Two columns: seventeen cards in one list makes the dialog scroll,
|
|
||||||
* which hides most of the catalogue behind a scrollbar. Groups flow
|
|
||||||
* down the columns and are kept whole, so a group is never split
|
|
||||||
* across the gap.
|
|
||||||
*/}
|
|
||||||
<Box
|
|
||||||
pt={theme.spacing(LAYOUT.SM)}
|
|
||||||
sx={{
|
|
||||||
columnCount: { xs: 1, sm: 2 },
|
|
||||||
columnGap: theme.spacing(LAYOUT.XL),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{grouped.map(({ group, cards }) => (
|
|
||||||
<Stack
|
|
||||||
key={group}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
pb={theme.spacing(LAYOUT.MD)}
|
|
||||||
sx={{ breakInside: "avoid" }}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="eyebrow"
|
|
||||||
color={theme.palette.text.disabled}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* The picker has its own group labels rather than reusing the
|
|
||||||
* sidebar's: the Logs page hosts logs, queue and diagnostics
|
|
||||||
* tabs, so "Logs" would misdescribe the three cards under it.
|
|
||||||
*/}
|
|
||||||
{t(`pages.dashboard.picker.groups.${group}`, {
|
|
||||||
defaultValue: group,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
{cards.map((card) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={card.id}
|
|
||||||
label={
|
|
||||||
<Typography color={theme.palette.text.primary}>
|
|
||||||
{t(`pages.dashboard.cards.${card.key}.title`)}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={chosen.has(card.id)}
|
|
||||||
onChange={() =>
|
|
||||||
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,275 +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 { Link as RouterLink } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Tooltip } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One thickness for every bar on the dashboard. Plain and segmented bars sit
|
|
||||||
* side by side across cards, so differing weights read as an inconsistency
|
|
||||||
* rather than as emphasis.
|
|
||||||
*/
|
|
||||||
const BAR_HEIGHT = 8;
|
|
||||||
|
|
||||||
export interface BarSegment {
|
|
||||||
key: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
/** Tooltip text, since the segments themselves carry no labels. */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A proportional bar. `value` and `max` share whatever unit the caller uses;
|
|
||||||
* percentages come in as 0-100, not as the API's 0-1 fractions.
|
|
||||||
*/
|
|
||||||
export const CardBar = ({
|
|
||||||
value,
|
|
||||||
max = 100,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
max?: number;
|
|
||||||
color: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
width="100%"
|
|
||||||
height={BAR_HEIGHT}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
bgcolor={theme.palette.action.hover}
|
|
||||||
sx={{ overflow: "hidden" }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={`${ratio * 100}%`}
|
|
||||||
height="100%"
|
|
||||||
bgcolor={color}
|
|
||||||
sx={{ transition: "width 0.3s ease" }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One bar divided into proportional segments — the shape of a whole split into
|
|
||||||
* parts, where the relative sizes are the point rather than any single value.
|
|
||||||
*
|
|
||||||
* Zero-value segments are dropped so they cannot appear as hairlines, and each
|
|
||||||
* segment carries a tooltip because the bar itself has no room for labels.
|
|
||||||
*/
|
|
||||||
export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const visible = segments.filter((segment) => segment.value > 0);
|
|
||||||
const total = visible.reduce((sum, segment) => sum + segment.value, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
width="100%"
|
|
||||||
height={BAR_HEIGHT}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
bgcolor={theme.palette.action.hover}
|
|
||||||
gap="1px"
|
|
||||||
sx={{ overflow: "hidden" }}
|
|
||||||
>
|
|
||||||
{total > 0 &&
|
|
||||||
visible.map((segment) => (
|
|
||||||
<Tooltip
|
|
||||||
key={segment.key}
|
|
||||||
title={segment.label}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={`${(segment.value / total) * 100}%`}
|
|
||||||
height="100%"
|
|
||||||
bgcolor={segment.color}
|
|
||||||
sx={{ transition: "width 0.3s ease" }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A clickable list row. Rows link to the entity they describe. */
|
|
||||||
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
{...(to ? { component: RouterLink, to } : {})}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
px={theme.spacing(LAYOUT.XS)}
|
|
||||||
py={theme.spacing(LAYOUT.XS)}
|
|
||||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
color="inherit"
|
|
||||||
sx={{
|
|
||||||
textDecoration: "none",
|
|
||||||
...(to && {
|
|
||||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Name plus optional secondary line, truncating rather than wrapping. */
|
|
||||||
export const CardRowLabel = ({
|
|
||||||
primary,
|
|
||||||
secondary,
|
|
||||||
}: {
|
|
||||||
primary: string;
|
|
||||||
secondary?: string | null;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
minWidth={0}
|
|
||||||
flex={1}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{primary}
|
|
||||||
</Typography>
|
|
||||||
{secondary && (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{secondary}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A single large figure with a caption — the shape of the stat cards. */
|
|
||||||
export const CardFigure = ({
|
|
||||||
value,
|
|
||||||
caption,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
caption?: string;
|
|
||||||
color?: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
|
||||||
<Typography
|
|
||||||
fontSize={typographyLevels.xxl}
|
|
||||||
fontWeight={300}
|
|
||||||
color={color ?? theme.palette.text.primary}
|
|
||||||
lineHeight={1.1}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
{caption && <Typography color={theme.palette.text.secondary}>{caption}</Typography>}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A label with its value on the right — the shape almost every stat card
|
|
||||||
* repeats. `color` highlights the value alone, leaving the label quiet.
|
|
||||||
*/
|
|
||||||
export const CardStatLine = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
color?: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
|
||||||
<Typography
|
|
||||||
color={color ?? theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A CardStatLine with a proportional bar beneath it — the ranking and
|
|
||||||
* breakdown row. `max` defaults to a percentage scale; pass the largest value
|
|
||||||
* in the set to make bars relative to each other instead.
|
|
||||||
*
|
|
||||||
* `label` may be a node so callers can put a "N down" flag beside the name.
|
|
||||||
*/
|
|
||||||
export const CardMeterRow = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
max,
|
|
||||||
color,
|
|
||||||
trailing,
|
|
||||||
}: {
|
|
||||||
label: ReactNode;
|
|
||||||
value: number;
|
|
||||||
max?: number;
|
|
||||||
color: string;
|
|
||||||
trailing?: ReactNode;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
{typeof label === "string" ? (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
{trailing}
|
|
||||||
</Stack>
|
|
||||||
<CardBar
|
|
||||||
value={value}
|
|
||||||
max={max}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
import { Icon, Tooltip } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
// Snug around the 16px icon without inflating the card header.
|
|
||||||
const CONTROL_SIZE = 24;
|
|
||||||
const ICON_SIZE = 16;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move-up / move-down controls, revealed on hover over the card they belong to.
|
|
||||||
* Kept out of DashboardCard so the 17 card components stay unaware of their own
|
|
||||||
* position — the grid owns ordering.
|
|
||||||
*/
|
|
||||||
export const CardReorderControls = ({
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
canMoveUp,
|
|
||||||
canMoveDown,
|
|
||||||
}: {
|
|
||||||
onMoveUp: () => void;
|
|
||||||
onMoveDown: () => void;
|
|
||||||
canMoveUp: boolean;
|
|
||||||
canMoveDown: boolean;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const buttonSx = {
|
|
||||||
width: CONTROL_SIZE,
|
|
||||||
height: CONTROL_SIZE,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
},
|
|
||||||
"&.Mui-disabled": { color: theme.palette.text.disabled },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
p={theme.spacing(LAYOUT.XXS)}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
bgcolor={theme.palette.background.paper}
|
|
||||||
border={`1px solid ${theme.palette.divider}`}
|
|
||||||
>
|
|
||||||
<Tooltip title={t("pages.dashboard.reorder.moveUp")}>
|
|
||||||
{/* span keeps the tooltip working while the button is disabled */}
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={!canMoveUp}
|
|
||||||
onClick={onMoveUp}
|
|
||||||
aria-label={t("pages.dashboard.reorder.moveUp")}
|
|
||||||
sx={buttonSx}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={ChevronUp}
|
|
||||||
size={ICON_SIZE}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t("pages.dashboard.reorder.moveDown")}>
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={!canMoveDown}
|
|
||||||
onClick={onMoveDown}
|
|
||||||
aria-label={t("pages.dashboard.reorder.moveDown")}
|
|
||||||
sx={buttonSx}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={ChevronDown}
|
|
||||||
size={ICON_SIZE}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export const CardSlotContext = createContext<ReactNode>(null);
|
|
||||||
|
|
||||||
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
|
|
||||||
|
|
||||||
import type { QueueMetrics } from "@/Types/Queue";
|
|
||||||
import type { Log } from "@/Types/Log";
|
|
||||||
import type { User } from "@/Types/User";
|
|
||||||
|
|
||||||
const MAX_ERROR_ROWS = 5;
|
|
||||||
|
|
||||||
/** Reads as a single reassurance line while nothing is failing. */
|
|
||||||
export const ChecksOnScheduleCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
|
|
||||||
"/queue/metrics",
|
|
||||||
{},
|
|
||||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthy = data?.failingJobs === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.checksOnSchedule.title")}
|
|
||||||
to="/logs"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{healthy ? (
|
|
||||||
<CardMessage
|
|
||||||
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
|
|
||||||
jobs: data.jobs,
|
|
||||||
workers: data.workers.length,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CardFigure
|
|
||||||
value={String(data.failingJobs)}
|
|
||||||
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
/>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
|
|
||||||
<CardRow key={String(job.monitorId)}>
|
|
||||||
<CardRowLabel
|
|
||||||
primary={job.monitorUrl ?? String(job.monitorId)}
|
|
||||||
secondary={job.failReason}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{job.failCount}
|
|
||||||
</Typography>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
|
|
||||||
value={String(data.jobs)}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
|
|
||||||
value={String(data.activeJobs)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
|
|
||||||
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
|
|
||||||
* worded "recent" rather than "all".
|
|
||||||
*/
|
|
||||||
export const RecentErrorsCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<Log[]>(
|
|
||||||
"/logs",
|
|
||||||
{},
|
|
||||||
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { errors, warnings, newest } = useMemo(() => {
|
|
||||||
const relevant = (data ?? []).filter(
|
|
||||||
(entry) => entry.level === "error" || entry.level === "warn"
|
|
||||||
);
|
|
||||||
const sorted = [...relevant].sort(
|
|
||||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
errors: relevant.filter((entry) => entry.level === "error").length,
|
|
||||||
warnings: relevant.filter((entry) => entry.level === "warn").length,
|
|
||||||
newest: sorted.slice(0, MAX_ERROR_ROWS),
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.recentErrors.title")}
|
|
||||||
to="/logs"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
errors > 0 ? (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
|
|
||||||
</Typography>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{newest.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.recentErrors.warnings")}
|
|
||||||
value={String(warnings)}
|
|
||||||
/>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{newest.map((entry, index) => (
|
|
||||||
<CardRow key={`${entry.timestamp}-${index}`}>
|
|
||||||
<CardRowLabel
|
|
||||||
primary={entry.message}
|
|
||||||
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
color={
|
|
||||||
entry.level === "error"
|
|
||||||
? theme.palette.error.main
|
|
||||||
: theme.palette.warning.main
|
|
||||||
}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{formatTimestamp(entry.timestamp)}
|
|
||||||
</Typography>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Never empty — at minimum the current user exists. */
|
|
||||||
export const TeamCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
|
|
||||||
|
|
||||||
const { total, byRole, unverified } = useMemo(() => {
|
|
||||||
const users = data ?? [];
|
|
||||||
const roles = new Map<string, number>();
|
|
||||||
for (const user of users) {
|
|
||||||
for (const role of user.role ?? []) {
|
|
||||||
roles.set(role, (roles.get(role) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: users.length,
|
|
||||||
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
|
|
||||||
unverified: users.filter((user) => !user.isVerified).length,
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.team.title")}
|
|
||||||
to="/account/team"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardFigure
|
|
||||||
value={String(total)}
|
|
||||||
caption={t("pages.dashboard.cards.team.members", { count: total })}
|
|
||||||
/>
|
|
||||||
{byRole.map(([role, count]) => (
|
|
||||||
<CardStatLine
|
|
||||||
key={role}
|
|
||||||
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
|
|
||||||
value={String(count)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{unverified > 0 && (
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.team.unverified")}
|
|
||||||
value={String(unverified)}
|
|
||||||
color={theme.palette.warning.main}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardMeterRow, CardRow } from "../CardPrimitives";
|
|
||||||
|
|
||||||
export interface BreakdownBucket {
|
|
||||||
/** Stable key and displayed label. */
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
total: number;
|
|
||||||
down: number;
|
|
||||||
/** Where the row links to, if anywhere. */
|
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "How the fleet splits by X" — one proportional row per bucket, flagging any
|
|
||||||
* bucket with something down. Monitors by type and Monitors by group are the
|
|
||||||
* same card over different keys, so they share this shell.
|
|
||||||
*/
|
|
||||||
export const BreakdownCard = ({
|
|
||||||
titleKey,
|
|
||||||
emptyKey,
|
|
||||||
to,
|
|
||||||
buckets,
|
|
||||||
isLoading,
|
|
||||||
isStale,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
titleKey: string;
|
|
||||||
emptyKey: string;
|
|
||||||
to?: string;
|
|
||||||
buckets: BreakdownBucket[];
|
|
||||||
isLoading?: boolean;
|
|
||||||
isStale?: boolean;
|
|
||||||
error?: unknown;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t(titleKey)}
|
|
||||||
to={to}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
isStale={isStale}
|
|
||||||
>
|
|
||||||
{buckets.length === 0 ? (
|
|
||||||
<CardMessage text={t(emptyKey)} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{buckets.map((bucket) => (
|
|
||||||
<CardRow
|
|
||||||
key={bucket.key}
|
|
||||||
to={bucket.to}
|
|
||||||
>
|
|
||||||
<CardMeterRow
|
|
||||||
label={bucket.label}
|
|
||||||
value={bucket.total}
|
|
||||||
max={max}
|
|
||||||
color={
|
|
||||||
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
|
|
||||||
}
|
|
||||||
trailing={
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{bucket.down > 0 && (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
|
||||||
count: bucket.down,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{bucket.total}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import { latestCheck } from "../../checks";
|
|
||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
// <60% fine, 60-85% warm, above that hot.
|
|
||||||
const WARM = 60;
|
|
||||||
const HOT = 85;
|
|
||||||
|
|
||||||
interface ServerUsage {
|
|
||||||
monitor: Monitor;
|
|
||||||
cpu: number | null;
|
|
||||||
memory: number | null;
|
|
||||||
disk: number | null;
|
|
||||||
peak: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Metric = ({ label, value }: { label: string; value: number | null }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const color =
|
|
||||||
value === null
|
|
||||||
? theme.palette.text.secondary
|
|
||||||
: value > HOT
|
|
||||||
? theme.palette.error.main
|
|
||||||
: value > WARM
|
|
||||||
? theme.palette.warning.main
|
|
||||||
: theme.palette.primary.main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
|
||||||
<Typography color={color}>
|
|
||||||
{/* A down server reports nothing — show a dash, never 0%. */}
|
|
||||||
{value === null ? "—" : `${value.toFixed(1)}%`}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<CardBar
|
|
||||||
value={value ?? 0}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BusiestServersCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const servers = useMemo<ServerUsage[]>(() => {
|
|
||||||
return (data?.monitors ?? [])
|
|
||||||
.filter((monitor) => monitor.type === "hardware")
|
|
||||||
.map((monitor) => {
|
|
||||||
const latest = latestCheck(monitor);
|
|
||||||
// snake_case here, unlike camelCase in HardwareCheckStats, and
|
|
||||||
// these are 0-1 fractions, so scale to percent.
|
|
||||||
const toPercent = (value: number | undefined) =>
|
|
||||||
typeof value === "number" ? value * 100 : null;
|
|
||||||
const cpu = toPercent(latest?.cpu?.usage_percent);
|
|
||||||
const memory = toPercent(latest?.memory?.usage_percent);
|
|
||||||
const disks = (latest?.disk ?? [])
|
|
||||||
.map((entry) => entry.usage_percent)
|
|
||||||
.filter((value): value is number => typeof value === "number");
|
|
||||||
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
|
|
||||||
return {
|
|
||||||
monitor,
|
|
||||||
cpu,
|
|
||||||
memory,
|
|
||||||
disk,
|
|
||||||
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.peak - a.peak);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.busiestServers.title")}
|
|
||||||
to="/infrastructure"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{servers.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
|
||||||
{servers.map(({ monitor, cpu, memory, disk }) => (
|
|
||||||
<CardRow
|
|
||||||
key={monitor.id}
|
|
||||||
to={`/infrastructure/${monitor.id}`}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<CardRowLabel primary={monitor.name} />
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
|
||||||
>
|
|
||||||
<Metric
|
|
||||||
label={t("pages.dashboard.cards.busiestServers.cpu")}
|
|
||||||
value={cpu}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label={t("pages.dashboard.cards.busiestServers.memory")}
|
|
||||||
value={memory}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label={t("pages.dashboard.cards.busiestServers.disk")}
|
|
||||||
value={disk}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard } from "../DashboardCard";
|
|
||||||
import { CardBar, CardStatLine } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import { REFRESH_INTERVAL_MS } from "../../cards";
|
|
||||||
|
|
||||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
|
||||||
|
|
||||||
const BYTES_PER_MB = 1024 * 1024;
|
|
||||||
|
|
||||||
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
|
|
||||||
// heap warm past 80% of the V8 limit.
|
|
||||||
const EVENT_LOOP_WARM_MS = 50;
|
|
||||||
const EVENT_LOOP_HOT_MS = 200;
|
|
||||||
const HEAP_WARM_RATIO = 0.8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one card that is never empty — it reports on Checkmate itself, so it
|
|
||||||
* works on a fresh install with zero monitors. Admin only.
|
|
||||||
*/
|
|
||||||
export const CheckmateServerCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<Diagnostics>(
|
|
||||||
"/diagnostic/system",
|
|
||||||
{},
|
|
||||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
|
|
||||||
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
|
|
||||||
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
|
|
||||||
const heapColor =
|
|
||||||
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
|
|
||||||
|
|
||||||
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
|
|
||||||
const eventLoopColor =
|
|
||||||
eventLoopMs > EVENT_LOOP_HOT_MS
|
|
||||||
? theme.palette.error.main
|
|
||||||
: eventLoopMs > EVENT_LOOP_WARM_MS
|
|
||||||
? theme.palette.warning.main
|
|
||||||
: theme.palette.text.primary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.checkmateServer.title")}
|
|
||||||
to="/logs"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.memory")}
|
|
||||||
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
|
|
||||||
heapLimit / BYTES_PER_MB
|
|
||||||
)} MB`}
|
|
||||||
color={heapColor}
|
|
||||||
/>
|
|
||||||
<CardBar
|
|
||||||
value={heapRatio * 100}
|
|
||||||
color={heapColor}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
|
|
||||||
value={`${eventLoopMs.toFixed(1)} ms`}
|
|
||||||
color={eventLoopColor}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.uptime")}
|
|
||||||
value={formatDuration(data.uptimeMs)}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.database")}
|
|
||||||
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
|
|
||||||
reads: data.mongoStats.readsPerSecond.toFixed(1),
|
|
||||||
writes: data.mongoStats.writesPerSecond.toFixed(1),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,157 +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 { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
|
|
||||||
import { formatStatusCode } from "@/Utils/statusCode";
|
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardRow } from "../CardPrimitives";
|
|
||||||
import { Sparkline } from "../Sparkline";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import { latestCheck } from "../../checks";
|
|
||||||
|
|
||||||
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
const SPARKLINE_WIDTH = 80;
|
|
||||||
|
|
||||||
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
|
|
||||||
down: 1,
|
|
||||||
breached: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long the monitor has been down, from the oldest consecutive failing
|
|
||||||
* check. recentChecks is oldest-first, so the run is walked back from the end.
|
|
||||||
*/
|
|
||||||
const downSince = (monitor: Monitor): string | null => {
|
|
||||||
const checks = monitor.recentChecks ?? [];
|
|
||||||
let oldestFailing: string | null = null;
|
|
||||||
for (let index = checks.length - 1; index >= 0; index -= 1) {
|
|
||||||
if (checks[index].status !== false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
oldestFailing = checks[index].createdAt;
|
|
||||||
}
|
|
||||||
return oldestFailing;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CurrentlyDownCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const problems = useMemo(() => {
|
|
||||||
const monitors = data?.monitors ?? [];
|
|
||||||
return monitors
|
|
||||||
.filter(isProblem)
|
|
||||||
.map((monitor) => ({ monitor, since: downSince(monitor) }))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const rank =
|
|
||||||
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
|
|
||||||
if (rank !== 0) {
|
|
||||||
return rank;
|
|
||||||
}
|
|
||||||
// Tie-break on longest downtime: the older timestamp ranks first.
|
|
||||||
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.currentlyDown.title")}
|
|
||||||
to="/incidents"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
problems.length > 0 ? (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.currentlyDown.count", {
|
|
||||||
count: problems.length,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{problems.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{problems.map(({ monitor, since }) => (
|
|
||||||
<CardRow
|
|
||||||
key={monitor.id}
|
|
||||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={getStatusColor(monitor.status, theme)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{monitor.name}
|
|
||||||
</Typography>
|
|
||||||
{since && (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{formatDuration(Date.now() - new Date(since).getTime())}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
{/* Not StatusCodeLabel: its Tooltip is focusable, and a
|
|
||||||
focusable element inside the row's link is unreachable
|
|
||||||
by keyboard. */}
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{monitor.type} ·{" "}
|
|
||||||
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
width={SPARKLINE_WIDTH}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
<Sparkline
|
|
||||||
checks={monitor.recentChecks ?? []}
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardBar, CardFigure } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import type { ChecksSummary } from "@/Types/Check";
|
|
||||||
|
|
||||||
// Day only. `dateRange=week` returns figures identical to `day`, so a range
|
|
||||||
// selector here would silently lie.
|
|
||||||
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
|
|
||||||
|
|
||||||
export const FailedChecksCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<ChecksSummary>(SUMMARY_URL);
|
|
||||||
|
|
||||||
const total = data?.totalChecks ?? 0;
|
|
||||||
const failed = data?.downChecks ?? 0;
|
|
||||||
const percentage = total > 0 ? (failed / total) * 100 : 0;
|
|
||||||
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.failedChecks.title")}
|
|
||||||
to="/checks"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{total === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardFigure
|
|
||||||
value={`${percentage.toFixed(1)}%`}
|
|
||||||
caption={t("pages.dashboard.cards.failedChecks.ratio", {
|
|
||||||
failed: failed.toLocaleString(),
|
|
||||||
total: total.toLocaleString(),
|
|
||||||
})}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
<CardBar
|
|
||||||
value={percentage}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user