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"
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`
```env
@@ -109,7 +110,7 @@ client/src/
- **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM
- **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)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL
- `ORIGIN`: Origin for CORS purposes
- `TOKEN_TTL`: Token time to live (in vercel/ms format)
- `LOG_LEVEL`: Debug level (debug, info, warn, error)
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`)
- `ORIGIN`: Origin for CORS purposes (optional)
- `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
- `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
- `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:
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
**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
**MongoDB connection issues:**
+7 -13
View File
@@ -32,12 +32,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![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" />
</p>
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
<br>
@@ -80,9 +80,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a>
## Installation
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
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:
```bash
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.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration
The image is configured entirely through environment variables on the server container:
@@ -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
- Incidents at a glance
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
@@ -211,9 +205,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [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!
+4 -34
View File
@@ -1,17 +1,16 @@
{
"name": "client",
"version": "3.11.0",
"version": "3.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.9.2",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
@@ -93,7 +92,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -409,7 +407,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +444,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -954,15 +950,6 @@
"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": {
"version": "9.3.0",
"license": "BSD-3-Clause"
@@ -1236,7 +1223,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1341,7 +1327,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2386,7 +2371,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2402,7 +2386,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2493,7 +2476,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2774,7 +2756,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3131,7 +3112,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3555,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3975,7 +3954,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4766,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5600,7 +5577,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6382,7 +6358,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6394,7 +6369,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6598,8 +6572,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6749,7 +6722,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7591,7 +7563,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7729,7 +7700,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+2 -5
View File
@@ -1,10 +1,9 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.9.2",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
@@ -12,8 +11,7 @@
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
"format-check": "prettier --check .",
"compile": "tsc -b"
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
@@ -22,7 +20,6 @@
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@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 IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
import { useTheme } from "@mui/material/styles";
export type ActionMenuItem = {
id: number | string;
@@ -13,7 +12,6 @@ export type ActionMenuItem = {
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl);
@@ -29,14 +27,7 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return (
<div>
<IconButton
onClick={handleClick}
sx={{
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
}}
>
<IconButton onClick={handleClick}>
<Settings
size={20}
strokeWidth={1.5}
@@ -1,7 +1,7 @@
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { CheckSnapshot } from "@/Types/Check";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
</Typography>
<Typography>
{t("common.labels.responseTime")}:{" "}
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
@@ -8,8 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
import { formatMs } from "@/Utils/TimeUtils";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -20,25 +18,25 @@ interface HistogramResponseTimeProps {
}
interface ResponseTimeStats {
max: string | null;
avg: string | null;
max: number | string;
avg: number | string;
}
const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) {
return { max: null, avg: null };
return { max: "-", avg: "-" };
}
const validChecks = checks.filter((check) => check.responseTime != null);
const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) {
return { max: null, avg: null };
return { max: "-", avg: "-" };
}
const responseTimes = validChecks.map((check) => check.responseTime);
const max = formatMs(Math.max(...responseTimes));
const avg = formatMs(
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.round(Math.max(...responseTimes));
const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
@@ -56,7 +54,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
@@ -70,28 +68,29 @@ export const HistogramResponseTime = ({
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
<Typography
variant="body2"
noWrap
const statsContent = showStats &&
(typeof stats.max === "number" || typeof stats.avg === "number") && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return (
<Stack
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${barHeights[index] ?? 0}%`;
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor =
check.status === true
? theme.palette.success.light
@@ -6,13 +6,12 @@ import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
import type { DateRange } from "@/Types/Query";
interface MonitorTimeFrameHeaderProps {
isLoading?: boolean;
hasDateRange?: boolean;
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
dateRange: string;
setDateRange: (dateRange: string) => void;
}
export const HeaderTimeRange = ({
@@ -26,7 +25,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: DateRange | null
newValue: string | null
) => {
if (newValue !== null) {
setDateRange(newValue);
@@ -1,12 +1,10 @@
import Box, { type BoxProps } from "@mui/material/Box";
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();
return (
<Box
ref={ref}
{...rest}
sx={{
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<{
icon?: React.ReactNode;
title: string;
rightTitle?: string;
width?: number | string;
maxWidth?: number | string;
flexBasis?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
export const BaseChart = ({
children,
title,
rightTitle,
width = "100%",
maxWidth = "100%",
flexBasis = "0%",
padding,
onClick,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
onClick={onClick}
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
flexBasis,
width: width,
maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}}
>
<Stack
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Stack
direction="row"
justifyContent={"space-between"}
<Typography
variant="eyebrow"
color="text.secondary"
>
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
{rightTitle && (
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{rightTitle}
</Typography>
)}
</Stack>
{title}
</Typography>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
@@ -83,17 +83,12 @@ export const Breadcrumb = ({
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
const thirdSegment = segments[2];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
// page/section/:id routes such as docker/host/:monitorId
const isSectionDetailsPage = Boolean(
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
);
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const hasSubPage = isActionPage || isDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage || isSectionDetailsPage) {
if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
+4 -14
View File
@@ -1,28 +1,18 @@
import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps {
color?: string;
size?: DotSize;
size?: string;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return (
<span
style={{
content: '""',
width: DOT_SIZE_PX[size],
height: DOT_SIZE_PX[size],
width: size,
height: size,
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
() => ({
circumference: 2 * Math.PI * radius,
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);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
}, 100);
return () => clearTimeout(timer);
}, [circumference, strokeLength]);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,7 +123,6 @@ export const DetailGauge = ({
lowerLabel,
lowerValue,
maxWidth = 225,
flexBasis = "0%",
}: {
title: string;
progress: number;
@@ -132,7 +131,6 @@ export const DetailGauge = ({
lowerLabel?: string;
lowerValue?: string | number;
maxWidth?: number;
flexBasis?: number | string;
}) => {
const theme = useTheme();
return (
@@ -140,7 +138,6 @@ export const DetailGauge = ({
icon={null}
title={title}
maxWidth={maxWidth}
flexBasis={flexBasis}
>
<Stack
alignItems={"center"}
@@ -5,15 +5,13 @@ interface IconProps {
size?: number;
strokeWidth?: number;
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 (
<Icon
size={size}
strokeWidth={strokeWidth}
color={color}
/>
);
};
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<>
<Dot />
<Typography>
Checking every {formatDuration(monitor?.interval, true)}
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography>
</>
)}
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
backgroundColor: theme.palette.error.dark,
color: "#FFFFFF",
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
px: theme.spacing(8),
py: theme.spacing(4),
transition: "top 1s ease-in-out",
@@ -6,7 +6,6 @@ import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
import type { ReactNode } from "react";
@@ -110,7 +109,7 @@ export const PageHeader = ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
maxWidth: "108ch",
maxWidth: 640,
}}
>
{description}
@@ -143,7 +142,7 @@ export const PageHeader = ({
alignItems: "center",
justifyContent: "center",
width: 36,
height: INPUT_BASE_HEIGHT,
height: 32,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
@@ -1,5 +1,4 @@
import { Box, Stack, useTheme } from "@mui/material";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
@@ -45,7 +44,7 @@ export const SkeletonCard = ({
const blockStyle = {
width: 55,
height: INPUT_BASE_HEIGHT,
height: 34,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { darken, lighten } from "@mui/material/styles";
import { lighten } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
@@ -11,18 +11,17 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey;
sx?: SxProps;
interactive?: boolean;
}>;
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light";
const paper = theme.palette.background.paper;
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
const paperEnd = paper;
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%)`;
return (
@@ -31,12 +30,6 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
...(interactive && {
cursor: "pointer",
"&:hover": {
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
},
}),
...sx,
}}
>
@@ -69,8 +62,10 @@ export const StatBox = ({
return (
<GradientBox
palette={palette}
interactive={Boolean(onClick)}
sx={{ ...(sx as object) }}
sx={{
...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
>
<Stack onClick={onClick}>
<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 type { SxProps } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
sx={{
pointerEvents: "none",
backgroundImage: `
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
@@ -1,19 +1,12 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles";
import {
getDockerStatePalette,
getStatusPalette,
getValuePalette,
} from "@/Utils/MonitorUtils";
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
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 type ValueType = (typeof ValueTypes)[number];
@@ -117,36 +110,3 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
</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;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps<Theme>;
isRowSelected?: (row: T) => boolean;
};
export function DataTable<
@@ -71,7 +70,6 @@ export function DataTable<
emptyViewText,
emptyViewPositive,
getRowSx,
isRowSelected,
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -81,7 +79,6 @@ export function DataTable<
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) {
return (
@@ -111,11 +108,6 @@ export function DataTable<
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default",
...(onRowClick && {
"&:hover": {
backgroundColor: theme.palette.action.rowHover,
},
}),
}}
key={key}
>
@@ -199,7 +191,7 @@ export function DataTable<
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 600,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
@@ -235,25 +227,12 @@ export function DataTable<
{data.map((row) => {
const key = row.id || row._id || Math.random();
const isExpanded = expanded === key;
const selected = isRowSelected?.(row);
return (
<Fragment key={key}>
<TableRow
sx={{
cursor: isInteractive ? "pointer" : "default",
...(isInteractive && {
"&:hover .MuiTableCell-root": {
backgroundColor: selected
? theme.palette.action.selectedHover
: theme.palette.action.rowHover,
},
}),
...(selected && {
"& .MuiTableCell-root": {
backgroundColor: theme.palette.action.selected,
},
}),
cursor: onRowClick ? "pointer" : "default",
...(getRowSx?.(row) as object),
}}
onClick={() => {
@@ -313,12 +292,6 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
});
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
@@ -348,7 +321,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsRight
@@ -366,7 +338,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronRight
@@ -384,7 +355,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronLeft
@@ -402,7 +372,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsLeft
@@ -450,7 +419,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronsRight
@@ -468,7 +436,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronRight
@@ -486,7 +453,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleNextButtonClick}
disabled={hasMore === false}
aria-label="next page"
sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
<ChevronLeft
@@ -504,7 +470,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
<IconButton
disabled={hasMore === false}
aria-label="next page"
sx={controlHoverSx(theme)}
>
<Ellipsis
size={20}
@@ -1,8 +1,6 @@
import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
@@ -10,7 +8,7 @@ export const Tabs = (props: CustomTabsProps) => {
return (
<MuiTabs
sx={{
minHeight: INPUT_BASE_HEIGHT,
minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
@@ -40,9 +38,9 @@ export const Tab = (props: CustomTabProps) => {
iconPosition="start"
sx={{
textTransform: "none",
fontSize: typographyLevels.m,
fontSize: 14,
fontWeight: 500,
minHeight: INPUT_BASE_HEIGHT,
minHeight: 34,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
@@ -50,6 +48,7 @@ export const Tab = (props: CustomTabProps) => {
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
fontWeight: 600,
},
"&:hover": {
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 { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
fieldLabel?: string;
required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode;
placeholder?: string;
error?: boolean;
helperText?: string;
};
export const AutoCompleteInput = ({
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
required,
renderInput,
renderOptionContent,
placeholder,
error,
helperText,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const { t } = useTranslation();
const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => (
<TextField
{...params}
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
error={error}
helperText={helperText}
placeholder="Type to search"
/>
);
+2 -3
View File
@@ -1,7 +1,7 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
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 = [
"primary",
@@ -43,8 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props}
sx={{
textTransform: "none",
height: INPUT_BASE_HEIGHT,
lineHeight: 1.2,
height: 34,
fontWeight: 400,
borderRadius: 2,
whiteSpace: "nowrap",
+1 -2
View File
@@ -4,7 +4,6 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
@@ -19,7 +18,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: INPUT_BASE_HEIGHT,
height: 34,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
+2 -5
View File
@@ -8,7 +8,6 @@ import { FieldLabel } from "./FieldLabel";
import { Calendar } from "lucide-react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
@@ -56,7 +55,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse",
},
"& input": {
minHeight: INPUT_BASE_HEIGHT,
minHeight: 34,
p: 0,
pl: theme.spacing(3),
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":
{
borderColor: error
? theme.palette.error.main
: theme.palette.text.disabled,
borderColor: error ? theme.palette.error.main : theme.palette.divider,
},
},
},
+1 -18
View File
@@ -9,7 +9,6 @@ import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material/styles";
import type { ReactNode } from "react";
export const DialogInput = ({
@@ -26,8 +25,6 @@ export const DialogInput = ({
maxWidth,
fullWidth = false,
additionalButtons,
cancelSx,
onEscape,
}: {
open: boolean;
title?: string;
@@ -42,14 +39,6 @@ export const DialogInput = ({
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
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 theme = useTheme();
@@ -59,12 +48,7 @@ export const DialogInput = ({
maxWidth={maxWidth}
fullWidth={fullWidth}
onClose={(_event, reason) => {
if (reason === "backdropClick") return;
if (onEscape) {
onEscape();
return;
}
if (onCancel) onCancel(undefined);
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
}}
>
{title && (
@@ -110,7 +94,6 @@ export const DialogInput = ({
loading={loading}
variant="outlined"
onClick={onCancel}
sx={cancelSx}
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
-3
View File
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
"& .MuiButtonBase-root": {
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 { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
helperText?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
@@ -29,7 +27,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
}
if (isMultiple) {
@@ -72,7 +70,7 @@ const SelectInputInner = <T,>(
/>
)}
sx={{
height: `${INPUT_BASE_HEIGHT}px`,
height: "34px",
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
@@ -84,27 +82,21 @@ const SelectInputInner = <T,>(
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
...props.sx,
}}
/>
);
const helper = helperText ? (
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select}
{helper}
</Stack>
);
}
+9 -3
View File
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
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"> {
fieldLabel?: string;
@@ -22,10 +22,16 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 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":
{
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`,
caretColor: theme.palette.text.primary,
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 { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
error?: boolean;
helperText?: string;
}
export const TimePickerComponent = ({
fieldLabel,
required,
error,
helperText,
...props
}: TimePickerComponentProps) => {
const theme = useTheme();
@@ -34,17 +28,17 @@ export const TimePickerComponent = ({
sx: {
width: "fit-content",
"& input": {
minHeight: INPUT_BASE_HEIGHT,
minHeight: 34,
p: 0,
px: theme.spacing(5),
},
"& fieldset": {
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderColor: theme.palette.divider,
},
},
},
@@ -54,18 +48,14 @@ export const TimePickerComponent = ({
</LocalizationProvider>
);
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{picker}
{helperText && (
<Typography
variant="caption"
color={error ? "error" : "text.secondary"}
>
{helperText}
</Typography>
)}
</Stack>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{picker}
</Stack>
);
}
return picker;
};
@@ -4,7 +4,6 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
@@ -21,7 +20,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{
px: 8,
textTransform: "none",
height: INPUT_BASE_HEIGHT,
height: 34,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -35,6 +34,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
"&.Mui-selected": {
backgroundColor: selectedBg,
color: theme.palette.text.primary,
fontWeight: 500,
"&:hover": {
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}
padding={6}
overflow={"hidden"}
bgcolor={
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(124, 116, 116, 0.01)"
}
alignItems="center"
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12}
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(0, 0, 0, 0.01)",
display: "flex",
alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
>
<Stack
maxWidth={1280}
@@ -9,11 +9,12 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
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> = {
http: "HTTP",
ping: "Ping",
port: "Port",
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
import { formatMs } from "@/Utils/TimeUtils";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
>
Response Time:
</Typography>{" "}
{formatMs(selectedCheck.timings.total)}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller,
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 { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next";
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
interface MonitorStatBoxesProps {
monitor?: Monitor;
monitorStats: MonitorStats | null;
certificateExpiry?: string;
domainExpiry?: string;
}
export const MonitorStatBoxes = ({
monitor,
monitorStats,
certificateExpiry,
domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure);
const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const lastCheckTime = formatDuration(timeSinceLastCheck);
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)}
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/>
{monitor?.type === "http" && (
<>
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.domainExpiry")}
subtitle={domainExpiry || "N/A"}
/>
</>
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
)}
</Stack>
);
@@ -1,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 { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
import { useMemo, useState } from "react";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
PHASE_COLOR_KEYS,
PHASE_KEYS,
PHASE_LABEL_KEYS,
} from "@/Components/monitors/charts/ChartUtils";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
type DetailsCheck = GroupedCheck &
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
@@ -49,65 +63,17 @@ const ResponseTimeToolTip = ({
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.responseTime", {
value: formatMs(responseTime),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: formatMs(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
</Typography>
</Stack>
))}
<Typography>Response time: {responseTime} ms</Typography>
</BaseBox>
);
};
@@ -116,28 +82,11 @@ export const HistogramDetails = ({
checks,
range,
}: {
checks: DetailsCheck[];
checks: GroupedCheck[];
range: string;
}) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return (
<BaseChart
icon={
@@ -146,15 +95,7 @@ export const HistogramDetails = ({
strokeWidth={1.5}
/>
}
title={
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
title="Response times"
>
<ResponsiveContainer
width="100%"
@@ -187,34 +128,7 @@ export const HistogramDetails = ({
stopOpacity={0}
/>
</linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis
axisLine={false}
tickLine={false}
@@ -227,99 +141,24 @@ export const HistogramDetails = ({
)}
/>
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart>
);
};
@@ -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,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors/charts/XTick";
import { XTick } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
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 = ({
dateRange,
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors/charts/XTick";
import { XTick } from "@/Components/monitors";
import {
XAxis,
AreaChart,
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
</Typography>
) : null}
<Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{formatMs(avg)}</Typography>
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
</Stack>
);
};
@@ -7,7 +7,6 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { formatMs } from "@/Utils/TimeUtils";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation();
@@ -85,9 +84,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
<Typography
variant="h1"
textAlign={"center"}
>
{formatMs(avg)}
</Typography>
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
</Stack>
</BaseChart>
@@ -1,30 +0,0 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
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 { useState } from "react";
import { useNavigate } from "react-router";
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react";
import { MoreVertical, LogOut } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js";
import { setMode } from "@/Features/UI/uiSlice.js";
interface AuthFooterProps {
collapsed: boolean;
@@ -28,7 +27,6 @@ interface AuthFooterProps {
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation();
const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Typography
fontWeight={500}
noWrap
color={theme.palette.text.primary}
sx={{ color: theme.palette.text.primary }}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
noWrap
textTransform={"capitalize"}
color={theme.palette.text.secondary}
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
>
{getRoleText()}
</Typography>
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
pb={2}
sx={{ pointerEvents: "none" }}
>
<Typography fontWeight={500}>
<Typography
fontWeight={500}
fontSize={13}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
variant="body2"
textTransform={"capitalize"}
fontSize={12}
sx={{ textTransform: "capitalize" }}
>
{getRoleText()}
</Typography>
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
{item.name}
</MenuItem>
))}
<MenuItem
onClick={() => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
handleMenuClose();
}}
sx={menuItemSx}
>
<Icon icon={mode === "light" ? Moon : Sun} />
{mode === "light"
? t("pages.settings.form.ui.option.theme.dark")
: t("pages.settings.form.ui.option.theme.light")}
</MenuItem>
<MenuItem
onClick={handleLogout}
sx={menuItemSx}
+48 -85
View File
@@ -1,7 +1,6 @@
import { Icon } from "@/Components/design-elements";
import {
LayoutDashboard,
Globe,
Gauge,
Link,
@@ -19,100 +18,64 @@ import {
Lock,
Users,
Tag,
Waypoints,
Container,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
group: t("components.sidebar.menu.groups.monitoring"),
items: [
{
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} />,
},
],
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
group: t("components.sidebar.menu.groups.alerting"),
items: [
{
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} />,
},
],
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
{
group: t("components.sidebar.menu.groups.reporting"),
items: [
{
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} />,
},
],
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
group: t("components.sidebar.menu.groups.configuration"),
items: [
{
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.settings"),
path: "settings",
icon: <Icon icon={Settings} />,
},
],
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.tags"),
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
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
? theme.palette.sidebar.accent
: theme.palette.text.secondary;
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
? theme.palette.action.selectedHover
: theme.palette.action.rowHover;
? theme.palette.action.selected
: theme.palette.action.hover;
const fontWeight = selected ? 600 : 400;
return (
<Tooltip
+13 -52
View File
@@ -1,12 +1,9 @@
import { useEffect } from "react";
import Backdrop from "@mui/material/Backdrop";
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 Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -32,7 +29,7 @@ export const Sidebar = () => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { width, transition, transitionTiming, collapsed } = useSidebar();
const { width, transition, collapsed } = useSidebar();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
@@ -73,11 +70,11 @@ export const Sidebar = () => {
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
bgcolor={theme.palette.background.paper}
sx={{
touchAction: "none",
transition: transition,
zIndex: 1000,
backdropFilter: "blur(8px)",
}}
>
<List
@@ -92,55 +89,19 @@ export const Sidebar = () => {
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
{menu.map((group) => (
<Box key={group.group}>
<Box
position="relative"
overflow="hidden"
>
<Typography
variant="eyebrow"
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>
))}
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</List>
<StarPrompt />
<Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter
+1 -16
View File
@@ -2,14 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed"
| "docker";
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
interface TableState {
rowsPerPage: number;
@@ -21,11 +14,9 @@ interface SidebarState {
interface UIState {
monitors: TableState;
pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
docker: TableState;
logs: TableState;
sidebar: SidebarState;
mode: ThemeMode;
@@ -46,9 +37,6 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
pagespeed: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
@@ -58,9 +46,6 @@ const initialState: UIState = {
infrastructure: {
rowsPerPage: 5,
},
docker: {
rowsPerPage: 5,
},
logs: {
rowsPerPage: 15,
},
+1 -1
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
export interface ApiResponse<T> {
interface ApiResponse<T> {
success: boolean;
msg: string;
data: T;
+20 -23
View File
@@ -1,30 +1,27 @@
import { toast, type ToastOptions } from "react-toastify";
import { useMemo } from "react";
export const useToast = () => {
return useMemo(() => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
};
const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
const toastWarning = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "warning" });
const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
const toastWarning = (msg: string, opts?: ToastOptions) =>
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 { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import {
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -47,10 +42,8 @@ export const getMonitorDefaults = (
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
@@ -77,7 +70,6 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
};
break;
case "game":
@@ -145,10 +137,8 @@ export const getMonitorDefaults = (
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false,
matchMethod: DefaultMonitorMatchMethod,
matchMethod: "",
expectedValue: "",
jsonPath: "",
customUpCodes: [],
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
+80 -8
View File
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: data?.type ?? "email",
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
};
}
-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,
pagespeedApiKey: "",
systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
};
return { schema: settingsSchema, defaults };
+1 -3
View File
@@ -6,8 +6,7 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
export const useSidebar = () => {
const collapsed = useSelector(
@@ -20,7 +19,6 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
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 { FormProvider, useForm } from "react-hook-form";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import FormHelperText from "@mui/material/FormHelperText";
import { useForm, Controller } from "react-hook-form";
import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User";
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
interface RoleOption {
id: UserRole;
@@ -40,11 +43,10 @@ const EditUserPage = () => {
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
const form = useForm<EditUserFormData>({
const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
resolver,
defaultValues: defaults,
});
const { handleSubmit, reset } = form;
useEffect(() => {
if (user) {
@@ -62,6 +64,13 @@ const EditUserPage = () => {
name: t(`common.auth.roles.${role}`),
}));
const watchedRoles = watch("role");
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
const handleRemoveRole = (roleToRemove: UserRole) => {
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser =
isAdmin &&
userId !== currentUser?.id &&
@@ -81,31 +90,42 @@ const EditUserPage = () => {
};
return (
<FormProvider {...form}>
<BasePage
loading={isLoading}
component={"form"}
onSubmit={handleSubmit(onSubmit)}
>
<BasePage loading={isLoading}>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap={theme.spacing(8)}>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<FormTextField
<Controller
name="firstName"
autoComplete="given-name"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="given-name"
/>
)}
/>
<FormTextField
<Controller
name="lastName"
autoComplete="family-name"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="family-name"
/>
)}
/>
<TextField
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
@@ -119,11 +139,55 @@ const EditUserPage = () => {
title={t("pages.editUser.form.roles.title")}
subtitle={t("pages.editUser.form.roles.description")}
rightContent={
<FormMultiSelectField
name="role"
fieldLabel={t("common.form.role.option.role.label")}
options={roleOptions}
/>
<Stack spacing={theme.spacing(4)}>
<Controller
name="role"
control={control}
render={({ field, fieldState }) => (
<>
<Autocomplete
fieldLabel={t("common.form.role.option.role.label")}
multiple
options={roleOptions}
value={selectedRoles}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: RoleOption[]) => {
field.onChange(newValue.map((r) => r.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{selectedRoles.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedRoles.map((role, index) => (
<Stack
direction="row"
alignItems="center"
key={role.id}
width="100%"
>
<Typography flexGrow={1}>{role.name}</Typography>
<IconButton
size="small"
onClick={() => handleRemoveRole(role.id)}
aria-label="Remove role"
>
<Trash2 size={16} />
</IconButton>
{index < selectedRoles.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
}
/>
<Stack
@@ -153,19 +217,19 @@ const EditUserPage = () => {
</Button>
</Stack>
</Stack>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
</FormProvider>
</form>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
);
};
+82 -61
View File
@@ -1,15 +1,13 @@
import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { TextField, Button } from "@/Components/inputs";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { usePasswordForm } from "@/Hooks/usePasswordForm";
import { usePatch } from "@/Hooks/UseApi";
import type { PasswordFormData } from "@/Validation/password";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabPassword = () => {
const { t } = useTranslation();
@@ -17,11 +15,10 @@ export const TabPassword = () => {
const { resolver, defaults } = usePasswordForm();
const { patch, loading } = usePatch<FormData, void>();
const form = useForm<PasswordFormData>({
const { control, handleSubmit, reset } = useForm<PasswordFormData>({
resolver,
defaultValues: defaults,
});
const { handleSubmit, reset } = form;
const onSubmit = async (data: PasswordFormData) => {
const fd = new FormData();
@@ -35,61 +32,85 @@ export const TabPassword = () => {
};
return (
<FormProvider {...form}>
<Stack
gap={theme.spacing(LAYOUT.MD)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.currentPassword.title")}
subtitle={t("pages.account.form.currentPassword.description")}
rightContent={
<FormTextField
name="currentPassword"
type="password"
autoComplete="current-password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
<Stack
gap={theme.spacing(8)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.currentPassword.title")}
subtitle={t("pages.account.form.currentPassword.description")}
rightContent={
<Controller
name="currentPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
autoComplete="current-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="newPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.account.form.newPassword.option.newPassword.label"
)}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="newPassword"
type="password"
autoComplete="new-password"
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
/>
<FormTextField
name="confirm"
type="password"
autoComplete="new-password"
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
placeholder={t(
"pages.account.form.newPassword.option.confirm.placeholder"
)}
/>
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
placeholder={t(
"pages.account.form.newPassword.option.confirm.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
);
};
+64 -52
View File
@@ -1,11 +1,11 @@
import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { TextField, Button } from "@/Components/inputs";
import { ImageUpload } from "@/Components/inputs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { useSelector, useDispatch } from "react-redux";
import { useProfileForm } from "@/Hooks/useProfileForm";
import { usePatch } from "@/Hooks/UseApi";
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
import type { ProfileFormData } from "@/Validation/profile";
import type { RootState } from "@/Types/state";
import type { User } from "@/Types/User";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabProfile = () => {
const theme = useTheme();
@@ -24,7 +22,7 @@ export const TabProfile = () => {
const { resolver, defaults } = useProfileForm();
const { patch, loading: patchLoading } = usePatch<FormData, User>();
const form = useForm<ProfileFormData>({
const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
resolver,
defaultValues: {
firstName: user?.firstName ?? defaults.firstName,
@@ -33,7 +31,7 @@ export const TabProfile = () => {
deleteProfileImage: defaults.deleteProfileImage,
},
});
const { handleSubmit, setValue, watch } = form;
const currentImage = watch("profileImage");
const deleteImage = watch("deleteProfileImage");
@@ -76,52 +74,66 @@ export const TabProfile = () => {
};
return (
<FormProvider {...form}>
<Stack
gap={theme.spacing(LAYOUT.MD)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="firstName"
autoComplete="given-name"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
autoComplete="family-name"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
<Stack
gap={theme.spacing(8)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
autoComplete="given-name"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
autoComplete="family-name"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
);
};
@@ -1,16 +1,15 @@
import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Dialog } from "@/Components/inputs";
import { Dialog, TextField, Select } from "@/Components/inputs";
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { User } from "@/Types/User";
import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
interface AddTeamMemberDialogProps {
open: boolean;
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
const { schema, defaults } = useAddTeamMemberForm();
const { post, loading } = usePost<RegisterPayload, User>();
const form = useForm<AddTeamMemberFormData>({
const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
values: defaults,
});
const { handleSubmit, reset, setError } = form;
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => {
reset();
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
const onSubmit = async (data: AddTeamMemberFormData) => {
if (loading) return;
const { confirm: _confirm, ...userData } = data;
const { confirm, ...userData } = data;
const payload: RegisterPayload = userData;
const result = await post("/auth/users", payload);
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
};
return (
<FormProvider {...form}>
<Dialog
open={open}
title={t("pages.account.team.addMember.title")}
content={t("pages.account.team.addMember.description")}
onCancel={handleClose}
onConfirm={handleSubmit(onSubmit)}
confirmText={t("common.buttons.addMember")}
loading={loading}
maxWidth="sm"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
</Stack>
<FormTextField
name="email"
type="email"
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
<Dialog
open={open}
title={t("pages.account.team.addMember.title")}
content={t("pages.account.team.addMember.description")}
onCancel={handleClose}
onConfirm={handleSubmit(onSubmit)}
confirmText={t("common.buttons.addMember")}
loading={loading}
maxWidth="sm"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<RoleSelectField />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
sx={{ flex: 1 }}
/>
<FormTextField
name="confirm"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
/>
</Stack>
</Stack>
</Dialog>
</FormProvider>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
);
};
@@ -59,12 +59,7 @@ export const HeaderTeamControls = ({
<Button
variant="contained"
color="primary"
startIcon={
<Icon
icon={Mail}
size={16}
/>
}
startIcon={<Icon icon={Mail} />}
onClick={onInviteClick}
>
{t("common.buttons.inviteMember")}
@@ -74,12 +69,7 @@ export const HeaderTeamControls = ({
<Button
variant="contained"
color="primary"
startIcon={
<Icon
icon={UserPlus}
size={16}
/>
}
startIcon={<Icon icon={UserPlus} />}
onClick={onAddMemberClick}
>
{t("common.buttons.addMember")}
@@ -1,16 +1,16 @@
import { Stack } from "@mui/material";
import { useTheme, Typography } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { Dialog, TextField, Button } from "@/Components/inputs";
import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Select, Button } from "@/Components/inputs";
import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
@@ -31,12 +31,11 @@ export const InviteTeamMemberDialog = ({
const { t } = useTranslation();
const { resolver, defaults } = useInviteForm();
const form = useForm<InviteFormData>({
const { control, handleSubmit, reset } = useForm<InviteFormData>({
resolver,
defaultValues: defaults,
values: defaults,
});
const { handleSubmit, reset } = form;
const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData,
@@ -48,6 +47,12 @@ export const InviteTeamMemberDialog = ({
>();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data);
if (result?.data?.token) {
@@ -71,55 +76,88 @@ export const InviteTeamMemberDialog = ({
};
return (
<FormProvider {...form}>
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("common.buttons.generateToken")}
</Button>
}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<FormTextField
name="email"
type="email"
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<RoleSelectField />
{inviteLink && (
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("common.buttons.generateToken")}
</Button>
}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
<Select
{...field}
value={Array.isArray(value) ? (value[0] ?? "") : ""}
onChange={(e) => onChange([e.target.value as UserRole])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
</Stack>
</Dialog>
</FormProvider>
/>
{inviteLink && (
<>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
);
};
@@ -1,45 +0,0 @@
import MenuItem from "@mui/material/MenuItem";
import FormHelperText from "@mui/material/FormHelperText";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Select } from "@/Components/inputs";
import { UserRoles } from "@/Types/User";
import type { UserRole } from "@/Types/User";
export const RoleSelectField = () => {
const { t } = useTranslation();
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
name: "role",
});
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
value: role,
label: t(`common.auth.roles.${role}`),
}));
return (
<>
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
);
};
+49 -38
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login";
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const LoginPage = () => {
const { t } = useTranslation();
@@ -32,13 +31,11 @@ const LoginPage = () => {
const { schema, defaults } = useLoginForm();
const form = useForm<LoginFormData>({
const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit } = form;
if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => {
@@ -53,39 +50,53 @@ const LoginPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
<FormTextField
name="email"
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
</FormProvider>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
);
};
+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 { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const ForgotPasswordPage = () => {
const { t } = useTranslation();
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
const { schema, defaults } = useRecoveryForm();
const form = useForm<RecoveryFormData>({
const { control, handleSubmit } = useForm<RecoveryFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit } = form;
const onSubmit = async (data: RecoveryFormData) => {
if (loading) return;
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
<FormTextField
name="email"
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
);
};
+103 -70
View File
@@ -1,7 +1,7 @@
import { BaseAuthPage } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">;
@@ -46,11 +45,10 @@ const RegisterPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const form = useForm<RegisterFormData>({
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit, setError, reset } = form;
useEffect(() => {
if (!token || hasVerified.current) return;
@@ -66,14 +64,14 @@ const RegisterPage = () => {
navigate("/register", { replace: true });
}
});
}, [token, defaults, navigate, reset, verifyToken]);
}, [token]);
if (isCheckingAdmin) return null;
const onSubmit = async (data: RegisterFormData) => {
if (loading) return;
const { confirm: _confirm, ...userData } = data;
const { confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData };
if (token) {
payload.token = token;
@@ -91,70 +89,105 @@ const RegisterPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<FormTextField
name="email"
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
</FormProvider>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
);
};
+78 -66
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import { useDispatch } from "react-redux";
import { setAuthState } from "@/Features/Auth/authSlice";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const SetNewPasswordPage = () => {
const theme = useTheme();
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
const { schema, defaults } = useSetNewPasswordForm();
const form = useForm<SetNewPasswordFormData>({
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit, watch } = form;
const password = watch("password");
const confirm = watch("confirm");
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.setNewPassword.title")}
subtitle={t("pages.auth.setNewPassword.subtitle")}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.setNewPassword.title")}
subtitle={t("pages.auth.setNewPassword.subtitle")}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
);
};
+1 -2
View File
@@ -17,14 +17,13 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
import type { DateRange } from "@/Types/Query";
const Checks = () => {
const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>();
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 [page, setPage] = useState<number>(0);
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