Compare commits

..
Author SHA1 Message Date
gorkem-bwl 0b09895098 docs: align dev setup docs with current server config
## Changes
- Replace stale QUEUE_TYPE env var (superSimpleQueue/lessSimpleQueue) with QUEUE_MODE (primary/worker) to match server/src/config/envValidation.ts
- Mark ORIGIN, TOKEN_TTL and LOG_LEVEL as optional with their defaults; note that only CLIENT_HOST, JWT_SECRET and DB_CONNECTION_STRING are strictly required
- Note that CLIENT_HOST must be a full URL (enforced by env validation)
- Remove Redis port 6379 from the port-conflict troubleshooting list; the server has no Redis dependency
- Point CLAUDE.md at CONTRIBUTING.md as the setup source of truth

## Why
A contributor followed the setup docs and hit references to files and env vars that no longer exist after the repo reorg. This brings the contributor docs in line with the actual dev workflow.
2026-07-21 19:44:22 +03:00
406 changed files with 11489 additions and 27348 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+138
View File
@@ -0,0 +1,138 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+113
View File
@@ -0,0 +1,113 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+2 -1
View File
@@ -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
View File
@@ -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:**
+7 -13
View File
@@ -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>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](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!
+4 -34
View File
@@ -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
View File
@@ -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",
+1 -10
View File
@@ -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");
+4 -14
View File
@@ -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,
+1 -11
View File
@@ -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}
/> />
); );
+2 -3
View File
@@ -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",
+1 -2
View File
@@ -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": {
+2 -5
View File
@@ -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,
}, },
}, },
}, },
+1 -18
View File
@@ -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>
-3
View File
@@ -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,
},
}} }}
/> />
); );
+9 -17
View File
@@ -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>
); );
} }
+9 -3
View File
@@ -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",
+13 -23
View File
@@ -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
/>
)}
/>
);
};
+9 -7
View File
@@ -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>
);
};
-1
View File
@@ -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";
+9 -21
View File
@@ -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}
+48 -85
View File
@@ -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",
}, },
]; ];
}; };
+3 -2
View File
@@ -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
+13 -52
View File
@@ -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
+1 -16
View File
@@ -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,
}, },
+1 -1
View File
@@ -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;
+20 -23
View File
@@ -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 };
}, []);
}; };
+3 -13
View File
@@ -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>;
+80 -8
View File
@@ -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 || "",
}; };
} }
-22
View File
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
-2
View File
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "", pagespeedApiKey: "",
systemEmailPassword: "", systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
}; };
return { schema: settingsSchema, defaults }; return { schema: settingsSchema, defaults };
+1 -3
View File
@@ -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,
}; };
}; };
+103 -39
View File
@@ -1,6 +1,11 @@
import { TextField, Button } from "@/Components/inputs"; import { Trash2 } from "lucide-react";
import { TextField, Button, Autocomplete } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import 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>
); );
}; };
+82 -61
View File
@@ -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>
); );
}; };
+64 -52
View File
@@ -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>
)}
</>
);
};
+49 -38
View File
@@ -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>
); );
}; };
+35 -31
View File
@@ -1,13 +1,12 @@
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { 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>
); );
}; };
+103 -70
View File
@@ -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>
); );
}; };
+78 -66
View File
@@ -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>
); );
}; };
+1 -2
View File
@@ -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