Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b09895098 |
No files matched your search
@@ -1,22 +0,0 @@
|
|||||||
name: Lockfile Check (Client & Server)
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-lockfiles:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Validate with the same node image the production build uses, so a lock
|
|
||||||
# that passes here cannot fail the image build's npm ci.
|
|
||||||
- name: Resolve node image from Dockerfile
|
|
||||||
id: node-image
|
|
||||||
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Check server lockfile
|
|
||||||
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
|
||||||
|
|
||||||
- name: Check client lockfile
|
|
||||||
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: POEditor Translation Synchronization (disabled)
|
||||||
|
|
||||||
|
# This workflow is intentionally disabled — kept for reference only.
|
||||||
|
# The full definition is preserved commented-out below. To re-enable,
|
||||||
|
# delete the stub job and uncomment the section starting at `on:`.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
disabled:
|
||||||
|
if: false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "This workflow is disabled. See comments below for the original definition."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# on:
|
||||||
|
# # For manual triggering
|
||||||
|
# workflow_dispatch:
|
||||||
|
# inputs:
|
||||||
|
# languages:
|
||||||
|
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
||||||
|
# required: false
|
||||||
|
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
||||||
|
# format:
|
||||||
|
# description: "Export format (key_value_json or json)"
|
||||||
|
# required: false
|
||||||
|
# default: "key_value_json"
|
||||||
|
|
||||||
|
# permissions:
|
||||||
|
# contents: write
|
||||||
|
# pull-requests: write
|
||||||
|
|
||||||
|
# jobs:
|
||||||
|
# sync-translations:
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# permissions:
|
||||||
|
# contents: write
|
||||||
|
# issues: write
|
||||||
|
# pull-requests: write
|
||||||
|
|
||||||
|
# steps:
|
||||||
|
# - name: Checkout code
|
||||||
|
# uses: actions/checkout@v3
|
||||||
|
# with:
|
||||||
|
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# fetch-depth: 0
|
||||||
|
|
||||||
|
# - name: Setup Node.js
|
||||||
|
# uses: actions/setup-node@v3
|
||||||
|
# with:
|
||||||
|
# node-version: "22"
|
||||||
|
|
||||||
|
# - name: Create package.json for scripts
|
||||||
|
# run: |
|
||||||
|
# mkdir -p .github/scripts
|
||||||
|
# cat > .github/scripts/package.json << EOF
|
||||||
|
# {
|
||||||
|
# "name": "poeditor-scripts",
|
||||||
|
# "version": "1.0.0",
|
||||||
|
# "private": true,
|
||||||
|
# "type": "module",
|
||||||
|
# "dependencies": {
|
||||||
|
# "axios": "^1.6.0",
|
||||||
|
# "fs-extra": "^11.1.1"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# EOF
|
||||||
|
|
||||||
|
# - name: Install dependencies
|
||||||
|
# run: |
|
||||||
|
# cd .github/scripts
|
||||||
|
# npm install
|
||||||
|
|
||||||
|
# - name: Download translations from POEditor
|
||||||
|
# env:
|
||||||
|
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||||
|
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||||
|
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||||
|
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
||||||
|
# run: |
|
||||||
|
# mkdir -p temp
|
||||||
|
# node .github/scripts/download-translations.js
|
||||||
|
|
||||||
|
# - name: Verify translation files
|
||||||
|
# run: |
|
||||||
|
# echo "Verifying translation files..."
|
||||||
|
# for file in temp/*.json; do
|
||||||
|
# echo "Checking $file"
|
||||||
|
# if [ ! -s "$file" ]; then
|
||||||
|
# echo "Error: $file is empty or does not exist"
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
# # Validate JSON format
|
||||||
|
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
||||||
|
# done
|
||||||
|
# echo "All translation files are valid"
|
||||||
|
|
||||||
|
# - name: Copy translations to project
|
||||||
|
# run: |
|
||||||
|
# mkdir -p client/src/locales
|
||||||
|
# cp -r temp/* client/src/locales/
|
||||||
|
# echo "Translation files copied to client/src/locales/"
|
||||||
|
|
||||||
|
# - name: Get current date
|
||||||
|
# id: date
|
||||||
|
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# - name: Format client code
|
||||||
|
# run: |
|
||||||
|
# cd client
|
||||||
|
# npm ci
|
||||||
|
# npm run format
|
||||||
|
|
||||||
|
# - name: Create Pull Request
|
||||||
|
# uses: peter-evans/create-pull-request@v7
|
||||||
|
# with:
|
||||||
|
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# commit-message: "feat: translations updated from POEditor"
|
||||||
|
# title: "🌐 Updated Translations from POEditor"
|
||||||
|
# body: |
|
||||||
|
# This PR contains the latest translations from POEditor.
|
||||||
|
|
||||||
|
# 📅 Update Date: ${{ steps.date.outputs.date }}
|
||||||
|
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||||
|
|
||||||
|
# ⚠️ Please review the translations and approve the PR if everything looks correct.
|
||||||
|
# branch: translation-update-${{ github.run_number }}
|
||||||
|
# delete-branch: true
|
||||||
|
# base: develop
|
||||||
|
# add-paths: |
|
||||||
|
# client/src/locales/*.json
|
||||||
|
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||||
|
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
name: Upload Translations to POEditor on PR Merge
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- "client/src/locales/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-translations:
|
||||||
|
# Only run if the PR was merged (not just closed) or manually triggered
|
||||||
|
if: github.event.pull_request.merged == true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history to get changed files
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Create package.json for scripts
|
||||||
|
run: |
|
||||||
|
mkdir -p .github/scripts
|
||||||
|
cat > .github/scripts/package.json << EOF
|
||||||
|
{
|
||||||
|
"name": "poeditor-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
|
"form-data": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd .github/scripts
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Get changed locale files
|
||||||
|
id: changed-files
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
# Get base and head commits
|
||||||
|
BASE_SHA=${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA=${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
echo "Base SHA: $BASE_SHA"
|
||||||
|
echo "Head SHA: $HEAD_SHA"
|
||||||
|
|
||||||
|
# Get list of changed files in client/src/locales directory
|
||||||
|
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
|
||||||
|
|
||||||
|
if [ -z "$CHANGED_FILES" ]; then
|
||||||
|
echo "No changes detected in locale files"
|
||||||
|
echo "CHANGED_FILES=" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED_FILES"
|
||||||
|
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$CHANGED_FILES" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload changed translations to POEditor
|
||||||
|
if: env.CHANGED_FILES != ''
|
||||||
|
env:
|
||||||
|
POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||||
|
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||||
|
run: |
|
||||||
|
# Process each changed file
|
||||||
|
for FILE in $CHANGED_FILES; do
|
||||||
|
if [[ -f "$FILE" ]]; then
|
||||||
|
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
|
||||||
|
FILENAME=$(basename "$FILE")
|
||||||
|
|
||||||
|
# Special case: map gb.json to en language code
|
||||||
|
if [ "$FILENAME" == "gb.json" ]; then
|
||||||
|
LANG="en"
|
||||||
|
echo "Found gb.json, mapping to language code 'en'"
|
||||||
|
else
|
||||||
|
LANG=$(basename "$FILE" .json)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Processing $FILE for language $LANG"
|
||||||
|
|
||||||
|
# Upload to POEditor
|
||||||
|
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Notify on success
|
||||||
|
if: success() && env.CHANGED_FILES != ''
|
||||||
|
run: |
|
||||||
|
echo "Successfully uploaded translation files to POEditor."
|
||||||
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on no changes
|
||||||
|
if: env.CHANGED_FILES == ''
|
||||||
|
run: |
|
||||||
|
echo "No translation files were found to upload."
|
||||||
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
|
|||||||
ORIGIN="localhost"
|
ORIGIN="localhost"
|
||||||
LOG_LEVEL="debug"
|
LOG_LEVEL="debug"
|
||||||
```
|
```
|
||||||
|
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
|
||||||
|
|
||||||
### Client `.env`
|
### Client `.env`
|
||||||
```env
|
```env
|
||||||
|
|||||||
+7
-5
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
|
|||||||
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
|
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
|
||||||
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
|
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
|
||||||
- `DB_CONNECTION_STRING`: MongoDB connection URL
|
- `DB_CONNECTION_STRING`: MongoDB connection URL
|
||||||
- `ORIGIN`: Origin for CORS purposes
|
- `ORIGIN`: Origin for CORS purposes (optional)
|
||||||
- `TOKEN_TTL`: Token time to live (in vercel/ms format)
|
- `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
|
||||||
- `LOG_LEVEL`: Debug level (debug, info, warn, error)
|
- `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
|
||||||
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`)
|
- `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
|
||||||
|
|
||||||
|
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
|
||||||
|
|
||||||
Start the backend server:
|
Start the backend server:
|
||||||
|
|
||||||
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
|
|||||||
|
|
||||||
**Port already in use:**
|
**Port already in use:**
|
||||||
|
|
||||||
- Check if another service is using ports 5173, 52345, 27017, or 6379
|
- Check if another service is using ports 5173, 52345, or 27017
|
||||||
- Stop the conflicting service or change the port in `.env` files
|
- Stop the conflicting service or change the port in `.env` files
|
||||||
|
|
||||||
**MongoDB connection issues:**
|
**MongoDB connection issues:**
|
||||||
|
|||||||
@@ -32,13 +32,12 @@
|
|||||||
|
|
||||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||||
|
|
||||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
|
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=checkmate)
|
[](https://www.pikapods.com/pods?run=checkmate)
|
||||||
|
|
||||||
|
|
||||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||||
</p>
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
|
||||||
@@ -81,9 +80,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
|
|||||||
<a id="installation"></a>
|
<a id="installation"></a>
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
|
The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
|
||||||
|
|
||||||
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
||||||
@@ -92,10 +89,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
|||||||
|
|
||||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||||
|
|
||||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
|
||||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
|
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The image is configured entirely through environment variables on the server container:
|
The image is configured entirely through environment variables on the server container:
|
||||||
@@ -158,7 +151,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
- Selective disk monitoring with mountpoint selection
|
- Selective disk monitoring with mountpoint selection
|
||||||
- Incidents at a glance
|
- Incidents at a glance
|
||||||
- Status pages with 4 beautiful themes
|
- Status pages with 4 beautiful themes
|
||||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
@@ -212,9 +205,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
<a id="contributing"></a>
|
<a id="contributing"></a>
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
|
||||||
|
|
||||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
|
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
|
||||||
|
|
||||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
|
|||||||
+1
-2
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.11.0",
|
"version": "3.9.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build-dev": "tsc -b && vite build --mode development",
|
"build-dev": "tsc -b && vite build --mode development",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
{t("common.labels.responseTime")}:{" "}
|
{t("common.labels.responseTime")}:{" "}
|
||||||
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
|
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography textTransform={"capitalize"}>
|
<Typography textTransform={"capitalize"}>
|
||||||
Status:{" "}
|
Status:{" "}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
import { computeBarHeights } from "@/Utils/DataUtils";
|
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface HistogramResponseTimeProps {
|
interface HistogramResponseTimeProps {
|
||||||
checks: CheckSnapshot[];
|
checks: CheckSnapshot[];
|
||||||
@@ -20,25 +18,25 @@ interface HistogramResponseTimeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ResponseTimeStats {
|
interface ResponseTimeStats {
|
||||||
max: string | null;
|
max: number | string;
|
||||||
avg: string | null;
|
avg: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HEIGHT = 50;
|
const DEFAULT_HEIGHT = 50;
|
||||||
|
|
||||||
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
||||||
if (!Array.isArray(checks) || checks.length === 0) {
|
if (!Array.isArray(checks) || checks.length === 0) {
|
||||||
return { max: null, avg: null };
|
return { max: "-", avg: "-" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validChecks = checks.filter((check) => check.responseTime != null);
|
const validChecks = checks.filter((check) => check.originalResponseTime != null);
|
||||||
if (validChecks.length === 0) {
|
if (validChecks.length === 0) {
|
||||||
return { max: null, avg: null };
|
return { max: "-", avg: "-" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseTimes = validChecks.map((check) => check.responseTime);
|
const responseTimes = validChecks.map((check) => check.originalResponseTime);
|
||||||
const max = formatMs(Math.max(...responseTimes));
|
const max = Math.round(Math.max(...responseTimes));
|
||||||
const avg = formatMs(
|
const avg = Math.round(
|
||||||
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ export const HistogramResponseTime = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
||||||
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
|
|
||||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
@@ -70,28 +68,29 @@ export const HistogramResponseTime = ({
|
|||||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||||
const gridGap = gap ?? theme.spacing(0.5);
|
const gridGap = gap ?? theme.spacing(0.5);
|
||||||
|
|
||||||
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && (
|
const statsContent = showStats &&
|
||||||
<Stack
|
(typeof stats.max === "number" || typeof stats.avg === "number") && (
|
||||||
justifyContent="center"
|
<Stack
|
||||||
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
|
justifyContent="center"
|
||||||
width={110}
|
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
|
||||||
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
width={110}
|
||||||
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
||||||
>
|
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
noWrap
|
|
||||||
>
|
>
|
||||||
{t("common.charts.histogram.avg", { value: stats.avg })}
|
<Typography
|
||||||
</Typography>
|
variant="body2"
|
||||||
<Typography
|
noWrap
|
||||||
variant="body2"
|
>
|
||||||
noWrap
|
{t("common.charts.histogram.avg", { value: stats.avg })}
|
||||||
>
|
</Typography>
|
||||||
{t("common.charts.histogram.max", { value: stats.max })}
|
<Typography
|
||||||
</Typography>
|
variant="body2"
|
||||||
</Stack>
|
noWrap
|
||||||
);
|
>
|
||||||
|
{t("common.charts.histogram.max", { value: stats.max })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
|
|||||||
>
|
>
|
||||||
{data.map((check, index) => {
|
{data.map((check, index) => {
|
||||||
const isPlaceholder = (check as any).status === "placeholder";
|
const isPlaceholder = (check as any).status === "placeholder";
|
||||||
const heightPct = `${barHeights[index] ?? 0}%`;
|
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
||||||
const barColor =
|
const barColor =
|
||||||
check.status === true
|
check.status === true
|
||||||
? theme.palette.success.light
|
? theme.palette.success.light
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ type BaseChartProps = React.PropsWithChildren<{
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
maxWidth?: number | string;
|
maxWidth?: number | string;
|
||||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||||
onClick?: () => void;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const BaseChart = ({
|
export const BaseChart = ({
|
||||||
@@ -22,22 +21,16 @@ export const BaseChart = ({
|
|||||||
width = "100%",
|
width = "100%",
|
||||||
maxWidth = "100%",
|
maxWidth = "100%",
|
||||||
padding,
|
padding,
|
||||||
onClick,
|
|
||||||
}: BaseChartProps) => {
|
}: BaseChartProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<BaseBox
|
<BaseBox
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
sx={{
|
||||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: width,
|
width: width,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
...(onClick && {
|
|
||||||
cursor: "pointer",
|
|
||||||
"& .recharts-wrapper": { cursor: "pointer !important" },
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
@@ -47,7 +40,6 @@ export const BaseChart = ({
|
|||||||
<Typography
|
<Typography
|
||||||
variant="eyebrow"
|
variant="eyebrow"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ overflowWrap: "anywhere" }}
|
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
|
|
||||||
export type DotSize = (typeof DotSizes)[number];
|
|
||||||
|
|
||||||
const DOT_SIZE_PX: Record<DotSize, string> = {
|
|
||||||
xs: "2px",
|
|
||||||
sm: "4px",
|
|
||||||
md: "8px",
|
|
||||||
lg: "12px",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DotProps {
|
interface DotProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: DotSize;
|
size?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
|
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
content: '""',
|
content: '""',
|
||||||
width: DOT_SIZE_PX[size],
|
width: size,
|
||||||
height: DOT_SIZE_PX[size],
|
height: size,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ interface IconProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
stroke?: string;
|
stroke?: string;
|
||||||
color?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
|
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
size={size}
|
size={size}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
color={color}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
||||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
<Typography>
|
<Typography>
|
||||||
Checking every {formatDuration(monitor?.interval, true)}
|
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
type AutoCompleteInputProps = Omit<
|
type AutoCompleteInputProps = Omit<
|
||||||
AutocompleteProps<any, boolean, boolean, boolean>,
|
AutocompleteProps<any, boolean, boolean, boolean>,
|
||||||
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
|
|||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
renderOptionContent?: (option: any) => React.ReactNode;
|
renderOptionContent?: (option: any) => React.ReactNode;
|
||||||
placeholder?: string;
|
|
||||||
error?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AutoCompleteInput = ({
|
export const AutoCompleteInput = ({
|
||||||
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
|
|||||||
required,
|
required,
|
||||||
renderInput,
|
renderInput,
|
||||||
renderOptionContent,
|
renderOptionContent,
|
||||||
placeholder,
|
|
||||||
error,
|
|
||||||
helperText,
|
|
||||||
...props
|
...props
|
||||||
}: AutoCompleteInputProps) => {
|
}: AutoCompleteInputProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
|
||||||
const multiple = props.multiple;
|
const multiple = props.multiple;
|
||||||
const { renderOption: customRenderOption, ...autocompleteProps } = props;
|
const { renderOption: customRenderOption, ...autocompleteProps } = props;
|
||||||
const defaultRenderInput = (params: any) => (
|
const defaultRenderInput = (params: any) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
|
placeholder="Type to search"
|
||||||
error={error}
|
|
||||||
helperText={helperText}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
|
|||||||
"& .MuiButtonBase-root": {
|
"& .MuiButtonBase-root": {
|
||||||
mr: theme.spacing(6),
|
mr: theme.spacing(6),
|
||||||
},
|
},
|
||||||
"&:has(.Mui-focusVisible)": {
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
|||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
helperText?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectInputInner = <T,>(
|
const SelectInputInner = <T,>(
|
||||||
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
|
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
) => {
|
) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -28,7 +27,7 @@ const SelectInputInner = <T,>(
|
|||||||
: selected === undefined || selected === null || selected === "";
|
: selected === undefined || selected === null || selected === "";
|
||||||
|
|
||||||
if (isEmpty && placeholder) {
|
if (isEmpty && placeholder) {
|
||||||
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
|
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMultiple) {
|
if (isMultiple) {
|
||||||
@@ -93,21 +92,11 @@ const SelectInputInner = <T,>(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const helper = helperText ? (
|
if (fieldLabel) {
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
|
|
||||||
>
|
|
||||||
{helperText}
|
|
||||||
</Typography>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (fieldLabel || helper) {
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={theme.spacing(2)}>
|
<Stack spacing={theme.spacing(2)}>
|
||||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||||
{select}
|
{select}
|
||||||
{helper}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
|
|||||||
import type { Dayjs } from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
@@ -11,15 +10,11 @@ import { LocalizationProvider } from "@mui/x-date-pickers";
|
|||||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
error?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimePickerComponent = ({
|
export const TimePickerComponent = ({
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
required,
|
required,
|
||||||
error,
|
|
||||||
helperText,
|
|
||||||
...props
|
...props
|
||||||
}: TimePickerComponentProps) => {
|
}: TimePickerComponentProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -38,12 +33,12 @@ export const TimePickerComponent = ({
|
|||||||
px: theme.spacing(5),
|
px: theme.spacing(5),
|
||||||
},
|
},
|
||||||
"& fieldset": {
|
"& fieldset": {
|
||||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
borderColor: theme.palette.divider,
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
},
|
},
|
||||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||||
{
|
{
|
||||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
borderColor: theme.palette.divider,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -53,18 +48,14 @@ export const TimePickerComponent = ({
|
|||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
if (fieldLabel) {
|
||||||
<Stack spacing={theme.spacing(2)}>
|
return (
|
||||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
<Stack spacing={theme.spacing(2)}>
|
||||||
{picker}
|
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||||
{helperText && (
|
{picker}
|
||||||
<Typography
|
</Stack>
|
||||||
variant="caption"
|
);
|
||||||
color={error ? "error" : "text.secondary"}
|
}
|
||||||
>
|
|
||||||
{helperText}
|
return picker;
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
|
||||||
|
|
||||||
export interface AutocompleteOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: O[];
|
|
||||||
fieldLabel?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormAutocompleteField = <
|
|
||||||
T extends FieldValues,
|
|
||||||
O extends AutocompleteOption,
|
|
||||||
>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fieldLabel,
|
|
||||||
placeholder,
|
|
||||||
}: FormAutocompleteProps<T, O>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Autocomplete
|
|
||||||
options={options}
|
|
||||||
getOptionLabel={(option) => option.name}
|
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
||||||
value={options.find((o) => o.id === field.value) ?? null}
|
|
||||||
onChange={(_: unknown, newValue: O | null) => {
|
|
||||||
field.onChange(newValue?.id ?? "");
|
|
||||||
}}
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
placeholder={placeholder}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
|
||||||
import { Checkbox } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormCheckboxFieldProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormCheckboxField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
}: FormCheckboxFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { ColorInput } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormColorFieldProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
fieldLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormColorField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
fieldLabel,
|
|
||||||
}: FormColorFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<ColorInput
|
|
||||||
format="hex"
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import dayjs, { type Dayjs } from "dayjs";
|
|
||||||
import { DatePicker, TimePicker } from "@/Components/inputs";
|
|
||||||
|
|
||||||
export interface FormPickerFieldProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
fieldLabel?: string;
|
|
||||||
format?: string;
|
|
||||||
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DayjsPickerProps {
|
|
||||||
fieldLabel?: string;
|
|
||||||
value: Dayjs | null;
|
|
||||||
onChange: (value: Dayjs | null) => void;
|
|
||||||
error?: boolean;
|
|
||||||
helperText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormDayjsPickerFieldProps<
|
|
||||||
T extends FieldValues,
|
|
||||||
> extends FormPickerFieldProps<T> {
|
|
||||||
picker: React.ComponentType<DayjsPickerProps>;
|
|
||||||
format: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormDayjsPickerField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
fieldLabel,
|
|
||||||
format,
|
|
||||||
onValueChange,
|
|
||||||
picker: Picker,
|
|
||||||
}: FormDayjsPickerFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Picker
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
value={field.value ? dayjs(field.value, format) : null}
|
|
||||||
onChange={(value) => {
|
|
||||||
const next = value ? value.format(format) : "";
|
|
||||||
field.onChange(next);
|
|
||||||
onValueChange?.(next);
|
|
||||||
}}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FormDatePickerField = <T extends FieldValues>({
|
|
||||||
format = "YYYY-MM-DD",
|
|
||||||
...rest
|
|
||||||
}: FormPickerFieldProps<T>) => (
|
|
||||||
<FormDayjsPickerField
|
|
||||||
{...rest}
|
|
||||||
format={format}
|
|
||||||
picker={DatePicker}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FormTimePickerField = <T extends FieldValues>({
|
|
||||||
format = "HH:mm",
|
|
||||||
...rest
|
|
||||||
}: FormPickerFieldProps<T>) => (
|
|
||||||
<FormDayjsPickerField
|
|
||||||
{...rest}
|
|
||||||
format={format}
|
|
||||||
picker={TimePicker}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Trash2, GripVertical } from "lucide-react";
|
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
|
||||||
import type { DropResult } from "@hello-pangea/dnd";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
|
||||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export interface MultiSelectOption {
|
|
||||||
id: string | number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: O[];
|
|
||||||
fieldLabel?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
description?: string;
|
|
||||||
renderRow?: (option: O) => React.ReactNode;
|
|
||||||
renderOptionContent?: (option: O) => React.ReactNode;
|
|
||||||
sortable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fieldLabel,
|
|
||||||
placeholder,
|
|
||||||
description,
|
|
||||||
renderRow,
|
|
||||||
renderOptionContent,
|
|
||||||
sortable = false,
|
|
||||||
}: FormMultiSelectFieldProps<T, O>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const optionsById = useMemo(
|
|
||||||
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
|
|
||||||
[options]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => {
|
|
||||||
const selectedIds: O["id"][] = field.value ?? [];
|
|
||||||
const selected = selectedIds
|
|
||||||
.map((id) => optionsById.get(id))
|
|
||||||
.filter((o): o is O => o !== undefined);
|
|
||||||
|
|
||||||
const handleDragEnd = (result: DropResult) => {
|
|
||||||
if (!result.destination) return;
|
|
||||||
const reordered = selected.map((o) => o.id);
|
|
||||||
const [moved] = reordered.splice(result.source.index, 1);
|
|
||||||
reordered.splice(result.destination.index, 0, moved);
|
|
||||||
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
|
|
||||||
field.onChange([...reordered, ...unresolved]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const rowContent = (option: O) =>
|
|
||||||
renderRow ? (
|
|
||||||
renderRow(option)
|
|
||||||
) : (
|
|
||||||
<Typography flexGrow={1}>{option.name}</Typography>
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeButton = (option: O) => (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
field.onChange(selectedIds.filter((id) => id !== option.id));
|
|
||||||
}}
|
|
||||||
aria-label={t("common.buttons.removeItem", { item: option.name })}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
autoHighlight
|
|
||||||
options={options}
|
|
||||||
value={selected}
|
|
||||||
getOptionLabel={(option) => option.name}
|
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
||||||
onChange={(_: unknown, newValue: O[]) => {
|
|
||||||
// The Autocomplete only sees resolved options, so ids missing
|
|
||||||
// from options must be re-appended or they'd be silently
|
|
||||||
// dropped (e.g. a role excluded from the selectable list).
|
|
||||||
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
|
|
||||||
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
|
|
||||||
}}
|
|
||||||
fieldLabel={fieldLabel}
|
|
||||||
placeholder={selected.length > 0 ? "" : placeholder}
|
|
||||||
renderOptionContent={renderOptionContent}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
/>
|
|
||||||
{description && (
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{sortable
|
|
||||||
? selected.length > 0 && (
|
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
|
||||||
<Droppable droppableId={`${name}-list`}>
|
|
||||||
{(provided) => (
|
|
||||||
<Stack
|
|
||||||
{...provided.droppableProps}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
>
|
|
||||||
{selected.map((option, index) => (
|
|
||||||
<Draggable
|
|
||||||
key={option.id}
|
|
||||||
draggableId={String(option.id)}
|
|
||||||
index={index}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<Stack
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
|
||||||
padding={theme.spacing(LAYOUT.XS)}
|
|
||||||
marginTop={theme.spacing(SPACING.LG)}
|
|
||||||
borderRadius={1}
|
|
||||||
border={`1px solid ${theme.palette.divider}`}
|
|
||||||
sx={{
|
|
||||||
cursor: "grab",
|
|
||||||
"&:active": { cursor: "grabbing" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GripVertical size={20} />
|
|
||||||
{rowContent(option)}
|
|
||||||
{removeButton(option)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
)
|
|
||||||
: selected.map((option) => (
|
|
||||||
<Stack
|
|
||||||
key={option.id}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
|
||||||
padding={theme.spacing(LAYOUT.XS)}
|
|
||||||
marginTop={theme.spacing(SPACING.LG)}
|
|
||||||
borderRadius={1}
|
|
||||||
border={`1px solid ${theme.palette.divider}`}
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
{rowContent(option)}
|
|
||||||
{removeButton(option)}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { TextField } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof TextField>,
|
|
||||||
"name" | "error" | "helperText" | "type"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormNumberField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
emptyValue = 0,
|
|
||||||
...rest
|
|
||||||
}: FormNumberFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
type="number"
|
|
||||||
value={
|
|
||||||
field.value === undefined || field.value === emptyValue ? "" : field.value
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
field.onChange(val === "" ? emptyValue : Number(val));
|
|
||||||
}}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? ""}
|
|
||||||
fullWidth
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { RadioGroup, useTheme } from "@mui/material";
|
|
||||||
import FormControl from "@mui/material/FormControl";
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { RadioWithDescription } from "@/Components/inputs/Radio";
|
|
||||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
|
||||||
export interface FormRadioOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormRadioGroupProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: FormRadioOption[];
|
|
||||||
fieldLabel?: string;
|
|
||||||
}
|
|
||||||
export const FormRadioGroup = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fieldLabel,
|
|
||||||
}: FormRadioGroupProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<FormControl error={!!fieldState.error}>
|
|
||||||
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
|
|
||||||
<RadioGroup
|
|
||||||
{...field}
|
|
||||||
sx={{ gap: theme.spacing(LAYOUT.MD) }}
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<RadioWithDescription
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
label={option.label}
|
|
||||||
description={option.description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldValues,
|
|
||||||
type FieldPath,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import { Select } from "@/Components/inputs";
|
|
||||||
|
|
||||||
export interface FormSelectOption {
|
|
||||||
value: string | number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormSelectProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof Select>,
|
|
||||||
"name" | "error" | "value" | "onChange"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
options: FormSelectOption[];
|
|
||||||
fallbackValue?: string | number; // display value while the field is undefined
|
|
||||||
onValueChange?: (value: string | number) => void; // side effects, see Notes
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSelectField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
fallbackValue,
|
|
||||||
onValueChange,
|
|
||||||
...rest
|
|
||||||
}: FormSelectProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? fallbackValue ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(e.target.value);
|
|
||||||
onValueChange?.(e.target.value as string | number);
|
|
||||||
}}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
{...rest}
|
|
||||||
helperText={fieldState.error?.message ?? rest.helperText}
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<MenuItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import { SliderWithLabel } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormSliderProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof SliderWithLabel>,
|
|
||||||
"name" | "value" | "onChange"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSliderField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
...rest
|
|
||||||
}: FormSliderProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<SliderWithLabel
|
|
||||||
{...field}
|
|
||||||
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { SwitchComponent } from "@/Components/inputs";
|
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
interface FormSwitchProps<T extends FieldValues> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
label: string;
|
|
||||||
labelPlacement?: "start" | "end"; // left | right
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSwitchField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
labelPlacement = "end",
|
|
||||||
}: FormSwitchProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
|
|
||||||
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
|
|
||||||
>
|
|
||||||
{labelPlacement === "start" ? (
|
|
||||||
<>
|
|
||||||
<Typography>{label}</Typography>
|
|
||||||
<SwitchComponent
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SwitchComponent
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<Typography>{label}</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
useFormContext,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form";
|
|
||||||
|
|
||||||
import { TextField } from "@/Components/inputs";
|
|
||||||
|
|
||||||
interface FormTextFieldProps<T extends FieldValues> extends Omit<
|
|
||||||
React.ComponentProps<typeof TextField>,
|
|
||||||
"name" | "error" | "helperText"
|
|
||||||
> {
|
|
||||||
name: FieldPath<T>;
|
|
||||||
helperText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormTextField = <T extends FieldValues>({
|
|
||||||
name,
|
|
||||||
...rest
|
|
||||||
}: FormTextFieldProps<T>) => {
|
|
||||||
const { control } = useFormContext<T>();
|
|
||||||
return (
|
|
||||||
<Controller
|
|
||||||
name={name}
|
|
||||||
control={control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
|
||||||
type="text"
|
|
||||||
{...rest}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
error={!!fieldState.error}
|
|
||||||
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -18,13 +18,15 @@ const RootLayout = () => {
|
|||||||
flex={1}
|
flex={1}
|
||||||
padding={6}
|
padding={6}
|
||||||
overflow={"hidden"}
|
overflow={"hidden"}
|
||||||
bgcolor={
|
sx={{
|
||||||
theme.palette.mode === "dark"
|
backgroundColor:
|
||||||
? "rgba(255, 255, 255, 0.01)"
|
theme.palette.mode === "dark"
|
||||||
: "rgba(124, 116, 116, 0.01)"
|
? "rgba(255, 255, 255, 0.01)"
|
||||||
}
|
: "rgba(0, 0, 0, 0.01)",
|
||||||
alignItems="center"
|
display: "flex",
|
||||||
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12}
|
alignItems: "center",
|
||||||
|
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
maxWidth={1280}
|
maxWidth={1280}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatStatusCode } from "@/Utils/statusCode";
|
import { formatStatusCode } from "@/Utils/statusCode";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface GeoChecksMapProps {
|
interface GeoChecksMapProps {
|
||||||
geoChecks: FlatGeoCheck[];
|
geoChecks: FlatGeoCheck[];
|
||||||
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
return status ? theme.palette.success.main : theme.palette.error.main;
|
return status ? theme.palette.success.main : theme.palette.error.main;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatResponseTime = (timing: number): string => {
|
||||||
|
return `${timing.toFixed(0)}ms`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mapPopupStyles}
|
{mapPopupStyles}
|
||||||
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
>
|
>
|
||||||
Response Time:
|
Response Time:
|
||||||
</Typography>{" "}
|
</Typography>{" "}
|
||||||
{formatMs(selectedCheck.timings.total)}
|
{formatResponseTime(selectedCheck.timings.total)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { StatBox } from "@/Components/design-elements";
|
import { StatBox } from "@/Components/design-elements";
|
||||||
|
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
||||||
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface MonitorStatBoxesProps {
|
interface MonitorStatBoxesProps {
|
||||||
monitor?: Monitor;
|
monitor?: Monitor;
|
||||||
monitorStats: MonitorStats | null;
|
monitorStats: MonitorStats | null;
|
||||||
certificateExpiry?: string;
|
certificateExpiry?: string;
|
||||||
domainExpiry?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MonitorStatBoxes = ({
|
export const MonitorStatBoxes = ({
|
||||||
monitor,
|
monitor,
|
||||||
monitorStats,
|
monitorStats,
|
||||||
certificateExpiry,
|
certificateExpiry,
|
||||||
domainExpiry,
|
|
||||||
}: MonitorStatBoxesProps) => {
|
}: MonitorStatBoxesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
|
|||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
||||||
|
|
||||||
const streakTime = formatDuration(timeSinceLastFailure);
|
const options = {
|
||||||
|
secondsDecimalDigits: 0,
|
||||||
|
millisecondsDecimalDigits: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const lastCheckTime = formatDuration(timeSinceLastCheck);
|
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
|
||||||
|
|
||||||
|
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
|
||||||
const isActive =
|
const isActive =
|
||||||
monitor?.status === "up" ||
|
monitor?.status === "up" ||
|
||||||
monitor?.status === "paused" ||
|
monitor?.status === "paused" ||
|
||||||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
|
|||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
||||||
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)}
|
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{monitor?.type === "http" && (
|
{monitor?.type === "http" && (
|
||||||
<>
|
<StatBox
|
||||||
<StatBox
|
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
subtitle={certificateExpiry || "N/A"}
|
||||||
subtitle={certificateExpiry || "N/A"}
|
/>
|
||||||
/>
|
|
||||||
<StatBox
|
|
||||||
title={t("pages.common.monitors.statBoxes.domainExpiry")}
|
|
||||||
subtitle={domainExpiry || "N/A"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +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",
|
|
||||||
};
|
|
||||||
@@ -1,39 +1,53 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
Text,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useTranslation } from "react-i18next";
|
import {
|
||||||
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
|
formatDateWithTz,
|
||||||
|
tickDateFormatLookup,
|
||||||
|
tooltipDateFormatLookup,
|
||||||
|
} from "@/Utils/TimeUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { Theme } from "@mui/material/styles";
|
import type { Theme } from "@mui/material/styles";
|
||||||
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
|
import type { GroupedCheck } from "@/Types/Check";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
type XTickProps = {
|
||||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
x: number;
|
||||||
import {
|
y: number;
|
||||||
PHASE_COLOR_KEYS,
|
payload: { value: string };
|
||||||
PHASE_KEYS,
|
range: string;
|
||||||
PHASE_LABEL_KEYS,
|
};
|
||||||
} from "@/Components/monitors/charts/ChartUtils";
|
|
||||||
|
|
||||||
type DetailsCheck = GroupedCheck &
|
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
||||||
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
|
const format = tickDateFormatLookup(range);
|
||||||
|
const theme = useTheme();
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
x={x}
|
||||||
|
y={y + 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={theme.palette.text.secondary}
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={400}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
range: string;
|
range: string;
|
||||||
@@ -49,65 +63,17 @@ const ResponseTimeToolTip = ({
|
|||||||
theme,
|
theme,
|
||||||
uiTimezone,
|
uiTimezone,
|
||||||
}: ResponseTimeToolTipProps) => {
|
}: ResponseTimeToolTipProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
const format = tooltipDateFormatLookup(range);
|
||||||
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
|
console.log(payload);
|
||||||
|
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
||||||
return (
|
return (
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||||
<Typography>
|
<Typography>Response time: {responseTime} ms</Typography>
|
||||||
{t("common.charts.histogram.responseTime", {
|
|
||||||
value: formatMs(responseTime),
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
|
|
||||||
range: string;
|
|
||||||
theme: Theme;
|
|
||||||
uiTimezone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TimingPhasesToolTip = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
range,
|
|
||||||
theme,
|
|
||||||
uiTimezone,
|
|
||||||
}: TimingPhaseToolTipProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
const bucket = payload[0].payload as GroupedUptimeCheck;
|
|
||||||
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
|
||||||
return (
|
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
|
||||||
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
|
|
||||||
<Typography>
|
|
||||||
{t("common.charts.histogram.total", { value: formatMs(total) })}
|
|
||||||
</Typography>
|
|
||||||
{PHASE_KEYS.map((key) => (
|
|
||||||
<Stack
|
|
||||||
alignItems={"center"}
|
|
||||||
direction="row"
|
|
||||||
key={key}
|
|
||||||
gap={SPACING.LG}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
size="lg"
|
|
||||||
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
/>
|
|
||||||
<Typography>
|
|
||||||
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -116,28 +82,11 @@ export const HistogramDetails = ({
|
|||||||
checks,
|
checks,
|
||||||
range,
|
range,
|
||||||
}: {
|
}: {
|
||||||
checks: DetailsCheck[];
|
checks: GroupedCheck[];
|
||||||
range: string;
|
range: string;
|
||||||
}) => {
|
}) => {
|
||||||
const [showTimingPhases, setShowTimingPhases] = useState(false);
|
|
||||||
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
const yMax = useMemo(() => {
|
|
||||||
const totals = checks.map((check) => {
|
|
||||||
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
|
|
||||||
return Math.max(check.avgResponseTime ?? 0, phaseSum);
|
|
||||||
});
|
|
||||||
return computeYAxisCap(totals);
|
|
||||||
}, [checks]);
|
|
||||||
|
|
||||||
const hasTimingPhases = useMemo(
|
|
||||||
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
|
|
||||||
[checks]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseChart
|
<BaseChart
|
||||||
icon={
|
icon={
|
||||||
@@ -146,15 +95,7 @@ export const HistogramDetails = ({
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={
|
title="Response times"
|
||||||
showTimingPhases
|
|
||||||
? t("common.charts.labels.timingPhases")
|
|
||||||
: t("common.charts.labels.responseTime")
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (!hasTimingPhases) return;
|
|
||||||
setShowTimingPhases((prev) => !prev);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ResponsiveContainer
|
<ResponsiveContainer
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -187,34 +128,7 @@ export const HistogramDetails = ({
|
|||||||
stopOpacity={0}
|
stopOpacity={0}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
{PHASE_KEYS.map((key) => (
|
|
||||||
<linearGradient
|
|
||||||
key={key}
|
|
||||||
id={`phaseGradient-${key}`}
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
stopOpacity={0.2}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
))}
|
|
||||||
</defs>
|
</defs>
|
||||||
<YAxis
|
|
||||||
hide
|
|
||||||
// scale="sqrt"
|
|
||||||
domain={[0, yMax ?? "auto"]}
|
|
||||||
allowDataOverflow
|
|
||||||
/>
|
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -227,99 +141,24 @@ export const HistogramDetails = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showTimingPhases ? (
|
<Tooltip
|
||||||
<Tooltip
|
content={(props) => (
|
||||||
content={(props) => (
|
<ResponseTimeToolTip
|
||||||
<TimingPhasesToolTip
|
{...props}
|
||||||
{...props}
|
range={range}
|
||||||
range={range}
|
theme={theme}
|
||||||
theme={theme}
|
uiTimezone={uiTimezone}
|
||||||
uiTimezone={uiTimezone}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
<Area
|
||||||
) : (
|
type="monotone"
|
||||||
<Tooltip
|
dataKey="avgResponseTime"
|
||||||
content={(props) => (
|
stroke={theme.palette.primary.main}
|
||||||
<ResponseTimeToolTip
|
fill="url(#colorUv)"
|
||||||
{...props}
|
/>
|
||||||
range={range}
|
|
||||||
theme={theme}
|
|
||||||
uiTimezone={uiTimezone}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!showTimingPhases && (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="avgResponseTime"
|
|
||||||
stroke={theme.palette.primary.main}
|
|
||||||
fill="url(#colorUv)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasTimingPhases &&
|
|
||||||
showTimingPhases &&
|
|
||||||
PHASE_KEYS.map((key) => {
|
|
||||||
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={key}
|
|
||||||
type="monotone"
|
|
||||||
stackId={1}
|
|
||||||
dataKey={key}
|
|
||||||
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
fill={`url(#phaseGradient-${key})`}
|
|
||||||
fillOpacity={dimmed ? 0.2 : undefined}
|
|
||||||
strokeOpacity={dimmed ? 0.2 : undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<Stack
|
|
||||||
justifyContent={"center"}
|
|
||||||
gap={LAYOUT.XS}
|
|
||||||
direction={"row"}
|
|
||||||
flexWrap={"wrap"}
|
|
||||||
>
|
|
||||||
{showTimingPhases ? (
|
|
||||||
PHASE_KEYS.map((key) => {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={SPACING.LG}
|
|
||||||
key={key}
|
|
||||||
onMouseEnter={() => setHoveredPhase(key)}
|
|
||||||
onMouseLeave={() => setHoveredPhase(null)}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={SPACING.LG}
|
|
||||||
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
|
|
||||||
onMouseLeave={() => setHoveredPhase(null)}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={theme.palette.primary.main}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<Typography lineHeight={1}>
|
|
||||||
{t("common.charts.labels.totalResponseTime")}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Fragment, useId } from "react";
|
import { Fragment, useId } from "react";
|
||||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
import { XTick } from "@/Components/monitors";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { TrendingUp } from "lucide-react";
|
import { TrendingUp } from "lucide-react";
|
||||||
import { XTick } from "@/Components/monitors/charts/XTick";
|
import { XTick } from "@/Components/monitors";
|
||||||
import {
|
import {
|
||||||
XAxis,
|
XAxis,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||||
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography variant="caption">{titleText}</Typography>
|
<Typography variant="caption">{titleText}</Typography>
|
||||||
<Typography variant="body2">{formatMs(avg)}</Typography>
|
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -85,9 +84,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
|
|||||||
<Typography
|
<Typography
|
||||||
variant="h1"
|
variant="h1"
|
||||||
textAlign={"center"}
|
textAlign={"center"}
|
||||||
>
|
>{`${avg?.toFixed()}ms`}</Typography>
|
||||||
{formatMs(avg)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
|
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import { Text } from "recharts";
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
import type { RootState } from "@/Types/state";
|
|
||||||
|
|
||||||
type XTickProps = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
payload: { value: string };
|
|
||||||
range: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
|
||||||
const format = tickDateFormatLookup(range);
|
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
x={x}
|
|
||||||
y={y + 10}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill={theme.palette.text.secondary}
|
|
||||||
fontSize={11}
|
|
||||||
fontWeight={400}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,11 +10,10 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react";
|
import { MoreVertical, LogOut } from "lucide-react";
|
||||||
import { Avatar, Icon } from "@/Components/design-elements";
|
import { Avatar, Icon } from "@/Components/design-elements";
|
||||||
import { clearAuthState } from "@/Features/Auth/authSlice.js";
|
import { clearAuthState } from "@/Features/Auth/authSlice.js";
|
||||||
import type { RootState } from "@/Types/state.js";
|
import type { RootState } from "@/Types/state.js";
|
||||||
import { setMode } from "@/Features/UI/uiSlice.js";
|
|
||||||
|
|
||||||
interface AuthFooterProps {
|
interface AuthFooterProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -28,7 +27,6 @@ interface AuthFooterProps {
|
|||||||
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
|
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const mode = useSelector((state: RootState) => state.ui.mode);
|
|
||||||
const user = useSelector((state: RootState) => state.auth.user);
|
const user = useSelector((state: RootState) => state.auth.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
<Typography
|
<Typography
|
||||||
fontWeight={500}
|
fontWeight={500}
|
||||||
noWrap
|
noWrap
|
||||||
color={theme.palette.text.primary}
|
sx={{ color: theme.palette.text.primary }}
|
||||||
>
|
>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
noWrap
|
||||||
textTransform={"capitalize"}
|
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
>
|
>
|
||||||
{getRoleText()}
|
{getRoleText()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
pb={2}
|
pb={2}
|
||||||
sx={{ pointerEvents: "none" }}
|
sx={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
<Typography fontWeight={500}>
|
<Typography
|
||||||
|
fontWeight={500}
|
||||||
|
fontSize={13}
|
||||||
|
>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
fontSize={12}
|
||||||
textTransform={"capitalize"}
|
sx={{ textTransform: "capitalize" }}
|
||||||
>
|
>
|
||||||
{getRoleText()}
|
{getRoleText()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
{item.name}
|
{item.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(setMode(mode === "light" ? "dark" : "light"));
|
|
||||||
handleMenuClose();
|
|
||||||
}}
|
|
||||||
sx={menuItemSx}
|
|
||||||
>
|
|
||||||
<Icon icon={mode === "light" ? Moon : Sun} />
|
|
||||||
{mode === "light"
|
|
||||||
? t("pages.settings.form.ui.option.theme.dark")
|
|
||||||
: t("pages.settings.form.ui.option.theme.light")}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
sx={menuItemSx}
|
sx={menuItemSx}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Users,
|
Users,
|
||||||
Tag,
|
Tag,
|
||||||
Waypoints,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const getMenu = (t: Function) => {
|
export const getMenu = (t: Function) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -47,11 +47,6 @@ export const getMenu = (t: Function) => {
|
|||||||
path: "tags",
|
path: "tags",
|
||||||
icon: <Icon icon={Tag} />,
|
icon: <Icon icon={Tag} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: t("components.sidebar.menu.proxies"),
|
|
||||||
path: "proxies",
|
|
||||||
icon: <Icon icon={Waypoints} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.checks"),
|
name: t("components.sidebar.menu.checks"),
|
||||||
path: "checks",
|
path: "checks",
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export const Sidebar = () => {
|
|||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
<StarPrompt />
|
<StarPrompt />
|
||||||
|
|
||||||
<Divider sx={{ borderColor: theme.palette.divider }} />
|
<Divider sx={{ borderColor: theme.palette.divider }} />
|
||||||
|
|
||||||
<AuthFooter
|
<AuthFooter
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||||
import {
|
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||||
DefaultHttpMethod,
|
|
||||||
DefaultMonitorMatchMethod,
|
|
||||||
DefaultPageSpeedStrategy,
|
|
||||||
DefaultProxyMode,
|
|
||||||
} from "@/Types/Monitor";
|
|
||||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface UseMonitorFormOptions {
|
interface UseMonitorFormOptions {
|
||||||
@@ -47,10 +42,8 @@ export const getMonitorDefaults = (
|
|||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
method: data?.method ?? DefaultHttpMethod,
|
method: data?.method ?? DefaultHttpMethod,
|
||||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||||
proxyMode: data?.proxyMode ?? DefaultProxyMode,
|
|
||||||
proxyId: data?.proxyId || "",
|
|
||||||
useAdvancedMatching: data?.useAdvancedMatching || false,
|
useAdvancedMatching: data?.useAdvancedMatching || false,
|
||||||
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
|
matchMethod: data?.matchMethod || "",
|
||||||
expectedValue: data?.expectedValue || "",
|
expectedValue: data?.expectedValue || "",
|
||||||
jsonPath: data?.jsonPath || "",
|
jsonPath: data?.jsonPath || "",
|
||||||
customUpCodes: data?.customUpCodes || [],
|
customUpCodes: data?.customUpCodes || [],
|
||||||
@@ -144,10 +137,8 @@ export const getMonitorDefaults = (
|
|||||||
url: "",
|
url: "",
|
||||||
method: DefaultHttpMethod,
|
method: DefaultHttpMethod,
|
||||||
ignoreTlsErrors: false,
|
ignoreTlsErrors: false,
|
||||||
proxyMode: DefaultProxyMode,
|
|
||||||
proxyId: "",
|
|
||||||
useAdvancedMatching: false,
|
useAdvancedMatching: false,
|
||||||
matchMethod: DefaultMonitorMatchMethod,
|
matchMethod: "",
|
||||||
expectedValue: "",
|
expectedValue: "",
|
||||||
jsonPath: "",
|
jsonPath: "",
|
||||||
customUpCodes: [],
|
customUpCodes: [],
|
||||||
|
|||||||
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDefaults(data: Notification | null): NotificationFormData {
|
function buildDefaults(data: Notification | null): NotificationFormData {
|
||||||
|
if (data?.type === "matrix") {
|
||||||
|
return {
|
||||||
|
type: "matrix",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
homeserverUrl: data.homeserverUrl || "",
|
||||||
|
roomId: data.roomId || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "telegram") {
|
||||||
|
return {
|
||||||
|
type: "telegram",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "slack") {
|
||||||
|
return {
|
||||||
|
type: "slack",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "discord") {
|
||||||
|
return {
|
||||||
|
type: "discord",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "webhook") {
|
||||||
|
return {
|
||||||
|
type: "webhook",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "pager_duty") {
|
||||||
|
return {
|
||||||
|
type: "pager_duty",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "teams") {
|
||||||
|
return {
|
||||||
|
type: "teams",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "twilio") {
|
||||||
|
return {
|
||||||
|
type: "twilio",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
accountSid: data.accountSid || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
phone: data.phone || "",
|
||||||
|
twilioPhoneNumber: data.twilioPhoneNumber || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "pushover") {
|
||||||
|
return {
|
||||||
|
type: "pushover",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
accessToken: data.accessToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data?.type === "ntfy") {
|
||||||
|
return {
|
||||||
|
type: "ntfy",
|
||||||
|
notificationName: data.notificationName || "",
|
||||||
|
address: data.address || "",
|
||||||
|
topic: data.topic || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Default: email (covers both data === null and data.type === "email")
|
||||||
return {
|
return {
|
||||||
type: data?.type ?? "email",
|
type: "email",
|
||||||
notificationName: data?.notificationName || "",
|
notificationName: data?.notificationName || "",
|
||||||
address: data?.address || "",
|
address: data?.address || "",
|
||||||
accessToken: data?.accessToken || "",
|
|
||||||
accountSid: data?.accountSid || "",
|
|
||||||
phone: data?.phone || "",
|
|
||||||
twilioPhoneNumber: data?.twilioPhoneNumber || "",
|
|
||||||
homeserverUrl: data?.homeserverUrl || "",
|
|
||||||
roomId: data?.roomId || "",
|
|
||||||
topic: data?.topic || "",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
|
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
|
||||||
|
|
||||||
interface UseProxiesFormOptions {
|
|
||||||
data?: ProxyResponse | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
|
|
||||||
return useMemo(() => {
|
|
||||||
const defaults: ProxyFormData = {
|
|
||||||
name: data?.name ?? "",
|
|
||||||
protocol: data?.protocol ?? "http",
|
|
||||||
host: data?.host ?? "",
|
|
||||||
port: data?.port ?? 8080,
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return { schema: proxySchema, defaults };
|
|
||||||
}, [data]);
|
|
||||||
};
|
|
||||||
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
|||||||
checkTTL: data?.checkTTL ?? 30,
|
checkTTL: data?.checkTTL ?? 30,
|
||||||
pagespeedApiKey: "",
|
pagespeedApiKey: "",
|
||||||
systemEmailPassword: "",
|
systemEmailPassword: "",
|
||||||
globalProxyEnabled: data?.globalProxyEnabled ?? false,
|
|
||||||
globalProxyId: data?.globalProxyId ?? null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { schema: settingsSchema, defaults };
|
return { schema: settingsSchema, defaults };
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { TextField, Button } from "@/Components/inputs";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { TextField, Button, Autocomplete } from "@/Components/inputs";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import FormHelperText from "@mui/material/FormHelperText";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { ConfigBox, BasePage } from "@/Components/design-elements";
|
import { ConfigBox, BasePage } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { UserRoles } from "@/Types/User";
|
import { UserRoles } from "@/Types/User";
|
||||||
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
|
||||||
|
|
||||||
interface RoleOption {
|
interface RoleOption {
|
||||||
id: UserRole;
|
id: UserRole;
|
||||||
@@ -40,11 +43,10 @@ const EditUserPage = () => {
|
|||||||
|
|
||||||
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
|
||||||
|
|
||||||
const form = useForm<EditUserFormData>({
|
const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -62,6 +64,13 @@ const EditUserPage = () => {
|
|||||||
name: t(`common.auth.roles.${role}`),
|
name: t(`common.auth.roles.${role}`),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const watchedRoles = watch("role");
|
||||||
|
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
|
||||||
|
const handleRemoveRole = (roleToRemove: UserRole) => {
|
||||||
|
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
|
||||||
|
setValue("role", newRoles, { shouldValidate: true });
|
||||||
|
};
|
||||||
|
|
||||||
const canDeleteUser =
|
const canDeleteUser =
|
||||||
isAdmin &&
|
isAdmin &&
|
||||||
userId !== currentUser?.id &&
|
userId !== currentUser?.id &&
|
||||||
@@ -81,31 +90,42 @@ const EditUserPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BasePage loading={isLoading}>
|
||||||
<BasePage
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
loading={isLoading}
|
|
||||||
component={"form"}
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
>
|
|
||||||
<Stack gap={theme.spacing(8)}>
|
<Stack gap={theme.spacing(8)}>
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.account.form.name.title")}
|
title={t("pages.account.form.name.title")}
|
||||||
subtitle={t("pages.account.form.name.description")}
|
subtitle={t("pages.account.form.name.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<Stack gap={theme.spacing(8)}>
|
<Stack gap={theme.spacing(8)}>
|
||||||
<FormTextField
|
<Controller
|
||||||
name="firstName"
|
name="firstName"
|
||||||
autoComplete="given-name"
|
control={control}
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
render={({ field, fieldState }) => (
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<FormTextField
|
<Controller
|
||||||
name="lastName"
|
name="lastName"
|
||||||
autoComplete="family-name"
|
control={control}
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
render={({ field, fieldState }) => (
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
@@ -119,11 +139,55 @@ const EditUserPage = () => {
|
|||||||
title={t("pages.editUser.form.roles.title")}
|
title={t("pages.editUser.form.roles.title")}
|
||||||
subtitle={t("pages.editUser.form.roles.description")}
|
subtitle={t("pages.editUser.form.roles.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<FormMultiSelectField
|
<Stack spacing={theme.spacing(4)}>
|
||||||
name="role"
|
<Controller
|
||||||
fieldLabel={t("common.form.role.option.role.label")}
|
name="role"
|
||||||
options={roleOptions}
|
control={control}
|
||||||
/>
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<Autocomplete
|
||||||
|
fieldLabel={t("common.form.role.option.role.label")}
|
||||||
|
multiple
|
||||||
|
options={roleOptions}
|
||||||
|
value={selectedRoles}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
onChange={(_: unknown, newValue: RoleOption[]) => {
|
||||||
|
field.onChange(newValue.map((r) => r.id));
|
||||||
|
}}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
/>
|
||||||
|
{fieldState.error && (
|
||||||
|
<FormHelperText error>{fieldState.error.message}</FormHelperText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{selectedRoles.length > 0 && (
|
||||||
|
<Stack
|
||||||
|
flex={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{selectedRoles.map((role, index) => (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
key={role.id}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Typography flexGrow={1}>{role.name}</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemoveRole(role.id)}
|
||||||
|
aria-label="Remove role"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
{index < selectedRoles.length - 1 && <Divider />}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Stack
|
<Stack
|
||||||
@@ -153,19 +217,19 @@ const EditUserPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
<DialogInput
|
</form>
|
||||||
open={showDeleteDialog}
|
<DialogInput
|
||||||
title={t("pages.editUser.dialog.removeUser.title")}
|
open={showDeleteDialog}
|
||||||
content={t("pages.editUser.dialog.removeUser.content", {
|
title={t("pages.editUser.dialog.removeUser.title")}
|
||||||
name: `${user?.firstName} ${user?.lastName}`,
|
content={t("pages.editUser.dialog.removeUser.content", {
|
||||||
})}
|
name: `${user?.firstName} ${user?.lastName}`,
|
||||||
onCancel={() => setShowDeleteDialog(false)}
|
})}
|
||||||
onConfirm={handleDeleteUser}
|
onCancel={() => setShowDeleteDialog(false)}
|
||||||
confirmText={t("common.buttons.removeUser")}
|
onConfirm={handleDeleteUser}
|
||||||
loading={isDeleting}
|
confirmText={t("common.buttons.removeUser")}
|
||||||
/>
|
loading={isDeleting}
|
||||||
</BasePage>
|
/>
|
||||||
</FormProvider>
|
</BasePage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { ConfigBox } from "@/Components/design-elements";
|
import { ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { TextField, Button } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { usePasswordForm } from "@/Hooks/usePasswordForm";
|
import { usePasswordForm } from "@/Hooks/usePasswordForm";
|
||||||
import { usePatch } from "@/Hooks/UseApi";
|
import { usePatch } from "@/Hooks/UseApi";
|
||||||
import type { PasswordFormData } from "@/Validation/password";
|
import type { PasswordFormData } from "@/Validation/password";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export const TabPassword = () => {
|
export const TabPassword = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -17,11 +15,10 @@ export const TabPassword = () => {
|
|||||||
const { resolver, defaults } = usePasswordForm();
|
const { resolver, defaults } = usePasswordForm();
|
||||||
const { patch, loading } = usePatch<FormData, void>();
|
const { patch, loading } = usePatch<FormData, void>();
|
||||||
|
|
||||||
const form = useForm<PasswordFormData>({
|
const { control, handleSubmit, reset } = useForm<PasswordFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
|
||||||
|
|
||||||
const onSubmit = async (data: PasswordFormData) => {
|
const onSubmit = async (data: PasswordFormData) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -35,61 +32,85 @@ export const TabPassword = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Stack
|
||||||
<Stack
|
gap={theme.spacing(8)}
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
>
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.account.form.currentPassword.title")}
|
||||||
title={t("pages.account.form.currentPassword.title")}
|
subtitle={t("pages.account.form.currentPassword.description")}
|
||||||
subtitle={t("pages.account.form.currentPassword.description")}
|
rightContent={
|
||||||
rightContent={
|
<Controller
|
||||||
<FormTextField
|
name="currentPassword"
|
||||||
name="currentPassword"
|
control={control}
|
||||||
type="password"
|
render={({ field, fieldState }) => (
|
||||||
autoComplete="current-password"
|
<TextField
|
||||||
fieldLabel={t("pages.account.form.currentPassword.option.label")}
|
{...field}
|
||||||
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
|
type="password"
|
||||||
|
fieldLabel={t("pages.account.form.currentPassword.option.label")}
|
||||||
|
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
|
||||||
|
autoComplete="current-password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.account.form.newPassword.title")}
|
||||||
|
subtitle={t("pages.account.form.newPassword.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack gap={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="newPassword"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.account.form.newPassword.option.newPassword.label"
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.account.form.newPassword.option.newPassword.placeholder"
|
||||||
|
)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
<Controller
|
||||||
/>
|
name="confirm"
|
||||||
<ConfigBox
|
control={control}
|
||||||
title={t("pages.account.form.newPassword.title")}
|
render={({ field, fieldState }) => (
|
||||||
subtitle={t("pages.account.form.newPassword.description")}
|
<TextField
|
||||||
rightContent={
|
{...field}
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
type="password"
|
||||||
<FormTextField
|
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
|
||||||
name="newPassword"
|
placeholder={t(
|
||||||
type="password"
|
"pages.account.form.newPassword.option.confirm.placeholder"
|
||||||
autoComplete="new-password"
|
)}
|
||||||
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")}
|
autoComplete="new-password"
|
||||||
placeholder={t(
|
error={!!fieldState.error}
|
||||||
"pages.account.form.newPassword.option.newPassword.placeholder"
|
helperText={fieldState.error?.message ?? ""}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
<FormTextField
|
/>
|
||||||
name="confirm"
|
</Stack>
|
||||||
type="password"
|
}
|
||||||
autoComplete="new-password"
|
/>
|
||||||
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
|
<Button
|
||||||
placeholder={t(
|
type="submit"
|
||||||
"pages.account.form.newPassword.option.confirm.placeholder"
|
variant="contained"
|
||||||
)}
|
color="primary"
|
||||||
/>
|
loading={loading}
|
||||||
</Stack>
|
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||||
}
|
>
|
||||||
/>
|
{t("common.buttons.save")}
|
||||||
<Button
|
</Button>
|
||||||
type="submit"
|
</Stack>
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
loading={loading}
|
|
||||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { ConfigBox } from "@/Components/design-elements";
|
import { ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { TextField, Button } from "@/Components/inputs";
|
||||||
import { ImageUpload } from "@/Components/inputs";
|
import { ImageUpload } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { useProfileForm } from "@/Hooks/useProfileForm";
|
import { useProfileForm } from "@/Hooks/useProfileForm";
|
||||||
import { usePatch } from "@/Hooks/UseApi";
|
import { usePatch } from "@/Hooks/UseApi";
|
||||||
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
|
|||||||
import type { ProfileFormData } from "@/Validation/profile";
|
import type { ProfileFormData } from "@/Validation/profile";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import type { User } from "@/Types/User";
|
import type { User } from "@/Types/User";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
export const TabProfile = () => {
|
export const TabProfile = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -24,7 +22,7 @@ export const TabProfile = () => {
|
|||||||
const { resolver, defaults } = useProfileForm();
|
const { resolver, defaults } = useProfileForm();
|
||||||
const { patch, loading: patchLoading } = usePatch<FormData, User>();
|
const { patch, loading: patchLoading } = usePatch<FormData, User>();
|
||||||
|
|
||||||
const form = useForm<ProfileFormData>({
|
const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
firstName: user?.firstName ?? defaults.firstName,
|
firstName: user?.firstName ?? defaults.firstName,
|
||||||
@@ -33,7 +31,7 @@ export const TabProfile = () => {
|
|||||||
deleteProfileImage: defaults.deleteProfileImage,
|
deleteProfileImage: defaults.deleteProfileImage,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { handleSubmit, setValue, watch } = form;
|
|
||||||
const currentImage = watch("profileImage");
|
const currentImage = watch("profileImage");
|
||||||
const deleteImage = watch("deleteProfileImage");
|
const deleteImage = watch("deleteProfileImage");
|
||||||
|
|
||||||
@@ -76,52 +74,66 @@ export const TabProfile = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Stack
|
||||||
<Stack
|
gap={theme.spacing(8)}
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
>
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.account.form.name.title")}
|
||||||
title={t("pages.account.form.name.title")}
|
subtitle={t("pages.account.form.name.description")}
|
||||||
subtitle={t("pages.account.form.name.description")}
|
rightContent={
|
||||||
rightContent={
|
<Stack gap={theme.spacing(8)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
<Controller
|
||||||
<FormTextField
|
name="firstName"
|
||||||
name="firstName"
|
control={control}
|
||||||
autoComplete="given-name"
|
render={({ field, fieldState }) => (
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
<TextField
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
{...field}
|
||||||
/>
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
<FormTextField
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
name="lastName"
|
autoComplete="given-name"
|
||||||
autoComplete="family-name"
|
error={!!fieldState.error}
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
/>
|
||||||
/>
|
)}
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.account.form.photo.title")}
|
|
||||||
subtitle={t("pages.account.form.photo.description")}
|
|
||||||
rightContent={
|
|
||||||
<ImageUpload
|
|
||||||
src={getCurrentImageSrc()}
|
|
||||||
onChange={handleImageChange}
|
|
||||||
/>
|
/>
|
||||||
}
|
<Controller
|
||||||
/>
|
name="lastName"
|
||||||
<Button
|
control={control}
|
||||||
type="submit"
|
render={({ field, fieldState }) => (
|
||||||
variant="contained"
|
<TextField
|
||||||
color="primary"
|
{...field}
|
||||||
loading={patchLoading}
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
>
|
autoComplete="family-name"
|
||||||
{t("common.buttons.save")}
|
error={!!fieldState.error}
|
||||||
</Button>
|
helperText={fieldState.error?.message ?? ""}
|
||||||
</Stack>
|
/>
|
||||||
</FormProvider>
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.account.form.photo.title")}
|
||||||
|
subtitle={t("pages.account.form.photo.description")}
|
||||||
|
rightContent={
|
||||||
|
<ImageUpload
|
||||||
|
src={getCurrentImageSrc()}
|
||||||
|
onChange={handleImageChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
loading={patchLoading}
|
||||||
|
sx={{ alignSelf: "flex-end", minWidth: 100 }}
|
||||||
|
>
|
||||||
|
{t("common.buttons.save")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { Dialog } from "@/Components/inputs";
|
import { Dialog, TextField, Select } from "@/Components/inputs";
|
||||||
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
|
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
|
||||||
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
|
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
|
||||||
import type { User } from "@/Types/User";
|
import type { UserRole, User } from "@/Types/User";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { RoleSelectField } from "./RoleSelectField";
|
|
||||||
|
|
||||||
interface AddTeamMemberDialogProps {
|
interface AddTeamMemberDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
|
|||||||
const { schema, defaults } = useAddTeamMemberForm();
|
const { schema, defaults } = useAddTeamMemberForm();
|
||||||
const { post, loading } = usePost<RegisterPayload, User>();
|
const { post, loading } = usePost<RegisterPayload, User>();
|
||||||
|
|
||||||
const form = useForm<AddTeamMemberFormData>({
|
const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
values: defaults,
|
values: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, reset, setError } = form;
|
const roleOptions: { value: UserRole; label: string }[] = [
|
||||||
|
{ value: "admin", label: t("common.auth.roles.admin") },
|
||||||
|
{ value: "user", label: t("common.auth.roles.user") },
|
||||||
|
{ value: "demo", label: t("common.auth.roles.demo") },
|
||||||
|
];
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
reset();
|
reset();
|
||||||
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
|
|||||||
const onSubmit = async (data: AddTeamMemberFormData) => {
|
const onSubmit = async (data: AddTeamMemberFormData) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
const { confirm: _confirm, ...userData } = data;
|
const { confirm, ...userData } = data;
|
||||||
const payload: RegisterPayload = userData;
|
const payload: RegisterPayload = userData;
|
||||||
|
|
||||||
const result = await post("/auth/users", payload);
|
const result = await post("/auth/users", payload);
|
||||||
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Dialog
|
||||||
<Dialog
|
open={open}
|
||||||
open={open}
|
title={t("pages.account.team.addMember.title")}
|
||||||
title={t("pages.account.team.addMember.title")}
|
content={t("pages.account.team.addMember.description")}
|
||||||
content={t("pages.account.team.addMember.description")}
|
onCancel={handleClose}
|
||||||
onCancel={handleClose}
|
onConfirm={handleSubmit(onSubmit)}
|
||||||
onConfirm={handleSubmit(onSubmit)}
|
confirmText={t("common.buttons.addMember")}
|
||||||
confirmText={t("common.buttons.addMember")}
|
loading={loading}
|
||||||
loading={loading}
|
maxWidth="sm"
|
||||||
maxWidth="sm"
|
fullWidth
|
||||||
fullWidth
|
>
|
||||||
>
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
<Stack
|
||||||
<Stack
|
direction="row"
|
||||||
direction="row"
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
>
|
||||||
>
|
<Controller
|
||||||
<FormTextField
|
name="firstName"
|
||||||
name="firstName"
|
control={control}
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
render={({ field, fieldState }) => (
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
<TextField
|
||||||
/>
|
{...field}
|
||||||
<FormTextField
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
name="lastName"
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
error={!!fieldState.error}
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
/>
|
sx={{ flex: 1 }}
|
||||||
</Stack>
|
/>
|
||||||
<FormTextField
|
)}
|
||||||
name="email"
|
/>
|
||||||
type="email"
|
<Controller
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
name="lastName"
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoleSelectField />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<FormTextField
|
|
||||||
name="password"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
type="password"
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="confirm"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Dialog>
|
<Controller
|
||||||
</FormProvider>
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
|
type="email"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value[0] ?? "user"}
|
||||||
|
onChange={(e) => field.onChange([e.target.value])}
|
||||||
|
fieldLabel={t("common.form.role.option.role.label")}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{roleOptions.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
type="password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirm"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
type="password"
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
import { useTheme, Typography } from "@mui/material";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { useTheme, FormHelperText, Typography } from "@mui/material";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { Dialog, TextField, Button } from "@/Components/inputs";
|
import { Dialog, TextField, Select, Button } from "@/Components/inputs";
|
||||||
|
import type { UserRole } from "@/Types/User";
|
||||||
import { useInviteForm } from "@/Hooks/useInviteForm";
|
import { useInviteForm } from "@/Hooks/useInviteForm";
|
||||||
import type { InviteFormData } from "@/Validation/invite";
|
import type { InviteFormData } from "@/Validation/invite";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { runtimeConfig } from "@/Utils/runtimeConfig";
|
import { runtimeConfig } from "@/Utils/runtimeConfig";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { RoleSelectField } from "./RoleSelectField";
|
|
||||||
|
|
||||||
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
|
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
|
||||||
|
|
||||||
@@ -31,12 +31,11 @@ export const InviteTeamMemberDialog = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { resolver, defaults } = useInviteForm();
|
const { resolver, defaults } = useInviteForm();
|
||||||
|
|
||||||
const form = useForm<InviteFormData>({
|
const { control, handleSubmit, reset } = useForm<InviteFormData>({
|
||||||
resolver,
|
resolver,
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
values: defaults,
|
values: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
|
||||||
|
|
||||||
const { post: generateToken, loading: generateLoading } = usePost<
|
const { post: generateToken, loading: generateLoading } = usePost<
|
||||||
InviteFormData,
|
InviteFormData,
|
||||||
@@ -48,6 +47,12 @@ export const InviteTeamMemberDialog = ({
|
|||||||
>();
|
>();
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const roleOptions: { value: UserRole; label: string }[] = [
|
||||||
|
{ value: "admin", label: t("common.auth.roles.admin") },
|
||||||
|
{ value: "user", label: t("common.auth.roles.user") },
|
||||||
|
{ value: "demo", label: t("common.auth.roles.demo") },
|
||||||
|
];
|
||||||
|
|
||||||
const handleGenerateToken = async (data: InviteFormData) => {
|
const handleGenerateToken = async (data: InviteFormData) => {
|
||||||
const result = await generateToken("/invite", data);
|
const result = await generateToken("/invite", data);
|
||||||
if (result?.data?.token) {
|
if (result?.data?.token) {
|
||||||
@@ -71,55 +76,88 @@ export const InviteTeamMemberDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<Dialog
|
||||||
<Dialog
|
open={open}
|
||||||
open={open}
|
title={t("pages.account.team.invite.title")}
|
||||||
title={t("pages.account.team.invite.title")}
|
content={t("pages.account.team.invite.description")}
|
||||||
content={t("pages.account.team.invite.description")}
|
onCancel={handleClose}
|
||||||
onCancel={handleClose}
|
onConfirm={handleSubmit(handleSendInvite)}
|
||||||
onConfirm={handleSubmit(handleSendInvite)}
|
confirmText={t("common.buttons.sendInvite")}
|
||||||
confirmText={t("common.buttons.sendInvite")}
|
loading={sendLoading || generateLoading}
|
||||||
loading={sendLoading || generateLoading}
|
maxWidth="sm"
|
||||||
maxWidth="sm"
|
fullWidth
|
||||||
fullWidth
|
additionalButtons={
|
||||||
additionalButtons={
|
<Button
|
||||||
<Button
|
variant="contained"
|
||||||
variant="contained"
|
color="primary"
|
||||||
color="primary"
|
onClick={handleSubmit(handleGenerateToken)}
|
||||||
onClick={handleSubmit(handleGenerateToken)}
|
loading={generateLoading || sendLoading}
|
||||||
loading={generateLoading || sendLoading}
|
>
|
||||||
>
|
{t("common.buttons.generateToken")}
|
||||||
{t("common.buttons.generateToken")}
|
</Button>
|
||||||
</Button>
|
}
|
||||||
}
|
>
|
||||||
>
|
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
<Controller
|
||||||
<FormTextField
|
name="email"
|
||||||
name="email"
|
control={control}
|
||||||
type="email"
|
render={({ field, fieldState }) => (
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
<TextField
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
{...field}
|
||||||
/>
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
<RoleSelectField />
|
type="email"
|
||||||
{inviteLink && (
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange, ...field }, fieldState }) => (
|
||||||
<>
|
<>
|
||||||
<Typography variant="body2">
|
<Select
|
||||||
{t("pages.account.team.invite.linkLabel")}
|
{...field}
|
||||||
</Typography>
|
value={Array.isArray(value) ? (value[0] ?? "") : ""}
|
||||||
<TextField
|
onChange={(e) => onChange([e.target.value as UserRole])}
|
||||||
value={inviteLink}
|
fieldLabel={t("common.form.role.option.role.label")}
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
error={!!fieldState.error}
|
||||||
input: {
|
>
|
||||||
readOnly: true,
|
{roleOptions.map((option) => (
|
||||||
},
|
<MenuItem
|
||||||
}}
|
key={option.value}
|
||||||
/>
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{fieldState.error && (
|
||||||
|
<FormHelperText error>{fieldState.error.message}</FormHelperText>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
/>
|
||||||
</Dialog>
|
{inviteLink && (
|
||||||
</FormProvider>
|
<>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{t("pages.account.team.invite.linkLabel")}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={inviteLink}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
|
||||||
import FormHelperText from "@mui/material/FormHelperText";
|
|
||||||
import { useController } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Select } from "@/Components/inputs";
|
|
||||||
import { UserRoles } from "@/Types/User";
|
|
||||||
import type { UserRole } from "@/Types/User";
|
|
||||||
|
|
||||||
export const RoleSelectField = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
|
|
||||||
name: "role",
|
|
||||||
});
|
|
||||||
|
|
||||||
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
|
|
||||||
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
|
|
||||||
value: role,
|
|
||||||
label: t(`common.auth.roles.${role}`),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value[0] ?? "user"}
|
|
||||||
onChange={(e) => field.onChange([e.target.value])}
|
|
||||||
fieldLabel={t("common.form.role.option.role.label")}
|
|
||||||
fullWidth
|
|
||||||
error={!!fieldState.error}
|
|
||||||
>
|
|
||||||
{roleOptions.map((option) => (
|
|
||||||
<MenuItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{fieldState.error && (
|
|
||||||
<FormHelperText error>{fieldState.error.message}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useLoginForm } from "@/Hooks/useLoginForm";
|
import { useLoginForm } from "@/Hooks/useLoginForm";
|
||||||
import type { LoginFormData } from "@/Validation/login";
|
import type { LoginFormData } from "@/Validation/login";
|
||||||
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { setAuthState } from "@/Features/Auth/authSlice";
|
import { setAuthState } from "@/Features/Auth/authSlice";
|
||||||
import { useLazyGet, usePost } from "@/Hooks/UseApi";
|
import { useLazyGet, usePost } from "@/Hooks/UseApi";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,13 +31,11 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
const { schema, defaults } = useLoginForm();
|
const { schema, defaults } = useLoginForm();
|
||||||
|
|
||||||
const form = useForm<LoginFormData>({
|
const { control, handleSubmit } = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit } = form;
|
|
||||||
|
|
||||||
if (isCheckingAdmin) return null;
|
if (isCheckingAdmin) return null;
|
||||||
|
|
||||||
const onSubmit = async (data: LoginFormData) => {
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
@@ -53,39 +50,53 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.login.title")}
|
||||||
title={t("pages.auth.login.title")}
|
subtitle={t("pages.auth.login.subtitle")}
|
||||||
subtitle={t("pages.auth.login.subtitle")}
|
>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<FormTextField
|
{t("pages.auth.login.submit")}
|
||||||
name="email"
|
</Button>
|
||||||
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
<TextLink
|
||||||
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
alignSelf={"center"}
|
||||||
/>
|
text={t("pages.auth.login.links.forgotPassword.text")}
|
||||||
<FormTextField
|
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
|
||||||
name="password"
|
href="/forgot-password"
|
||||||
type="password"
|
/>
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
</BaseAuthPage>
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t("pages.auth.login.submit")}
|
|
||||||
</Button>
|
|
||||||
<TextLink
|
|
||||||
alignSelf={"center"}
|
|
||||||
text={t("pages.auth.login.links.forgotPassword.text")}
|
|
||||||
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
|
|
||||||
href="/forgot-password"
|
|
||||||
/>
|
|
||||||
</BaseAuthPage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
|
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
|
||||||
import type { RecoveryFormData } from "@/Validation/recovery";
|
import type { RecoveryFormData } from "@/Validation/recovery";
|
||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
const ForgotPasswordPage = () => {
|
const ForgotPasswordPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
|
|||||||
|
|
||||||
const { schema, defaults } = useRecoveryForm();
|
const { schema, defaults } = useRecoveryForm();
|
||||||
|
|
||||||
const form = useForm<RecoveryFormData>({
|
const { control, handleSubmit } = useForm<RecoveryFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit } = form;
|
|
||||||
|
|
||||||
const onSubmit = async (data: RecoveryFormData) => {
|
const onSubmit = async (data: RecoveryFormData) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.forgotPassword.title")}
|
||||||
title={t("pages.auth.forgotPassword.title")}
|
subtitle={t("pages.auth.forgotPassword.subtitle")}
|
||||||
subtitle={t("pages.auth.forgotPassword.subtitle")}
|
>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<FormTextField
|
{t("pages.auth.forgotPassword.submit")}
|
||||||
name="email"
|
</Button>
|
||||||
fieldLabel={t("pages.auth.common.form.option.email.label")}
|
<TextLink
|
||||||
placeholder={t("pages.auth.common.form.option.email.placeholder")}
|
alignSelf={"center"}
|
||||||
/>
|
text={t("pages.auth.forgotPassword.links.login.text")}
|
||||||
<Button
|
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
||||||
variant="contained"
|
href="/login"
|
||||||
type="submit"
|
/>
|
||||||
loading={loading}
|
</BaseAuthPage>
|
||||||
>
|
|
||||||
{t("pages.auth.forgotPassword.submit")}
|
|
||||||
</Button>
|
|
||||||
<TextLink
|
|
||||||
alignSelf={"center"}
|
|
||||||
text={t("pages.auth.forgotPassword.links.login.text")}
|
|
||||||
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
|
||||||
href="/login"
|
|
||||||
/>
|
|
||||||
</BaseAuthPage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseAuthPage } from "@/Components/design-elements";
|
import { BaseAuthPage } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useRegisterForm } from "@/Hooks/useRegisterForm";
|
import { useRegisterForm } from "@/Hooks/useRegisterForm";
|
||||||
import type { RegisterFormData } from "@/Validation/register";
|
import type { RegisterFormData } from "@/Validation/register";
|
||||||
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import type { AuthResponse } from "@/Types/User";
|
import type { AuthResponse } from "@/Types/User";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
interface RegisterPayload {
|
interface RegisterPayload {
|
||||||
user: Omit<RegisterFormData, "confirm">;
|
user: Omit<RegisterFormData, "confirm">;
|
||||||
@@ -46,11 +45,10 @@ const RegisterPage = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const form = useForm<RegisterFormData>({
|
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, setError, reset } = form;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || hasVerified.current) return;
|
if (!token || hasVerified.current) return;
|
||||||
@@ -66,14 +64,14 @@ const RegisterPage = () => {
|
|||||||
navigate("/register", { replace: true });
|
navigate("/register", { replace: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [token, defaults, navigate, reset, verifyToken]);
|
}, [token]);
|
||||||
|
|
||||||
if (isCheckingAdmin) return null;
|
if (isCheckingAdmin) return null;
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterFormData) => {
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
const { confirm: _confirm, ...userData } = data;
|
const { confirm, ...userData } = data;
|
||||||
const payload: RegisterPayload = { user: userData };
|
const payload: RegisterPayload = { user: userData };
|
||||||
if (token) {
|
if (token) {
|
||||||
payload.token = token;
|
payload.token = token;
|
||||||
@@ -91,70 +89,105 @@ const RegisterPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.register.title")}
|
||||||
title={t("pages.auth.register.title")}
|
subtitle={t("pages.auth.register.subtitle")}
|
||||||
subtitle={t("pages.auth.register.subtitle")}
|
>
|
||||||
>
|
{!token && (
|
||||||
{!token && (
|
<Alert
|
||||||
<Alert
|
severity="info"
|
||||||
severity="info"
|
icon={false}
|
||||||
icon={false}
|
sx={(theme) => ({
|
||||||
sx={(theme) => ({
|
fontSize: 13,
|
||||||
fontSize: 13,
|
lineHeight: 1.55,
|
||||||
lineHeight: 1.55,
|
color: theme.palette.text.secondary,
|
||||||
color: theme.palette.text.secondary,
|
backgroundColor: theme.palette.action.hover,
|
||||||
backgroundColor: theme.palette.action.hover,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
borderRadius: 1,
|
||||||
borderRadius: 1,
|
"& .MuiAlert-message": { padding: 0 },
|
||||||
"& .MuiAlert-message": { padding: 0 },
|
})}
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t("pages.auth.register.setupNotice")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<FormTextField
|
|
||||||
name="firstName"
|
|
||||||
fieldLabel={t("common.form.name.option.firstName.label")}
|
|
||||||
placeholder={t("common.form.name.option.firstName.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="lastName"
|
|
||||||
fieldLabel={t("common.form.name.option.lastName.label")}
|
|
||||||
placeholder={t("common.form.name.option.lastName.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="email"
|
|
||||||
disabled={!!token}
|
|
||||||
fieldLabel={t("common.form.email.option.email.label")}
|
|
||||||
placeholder={t("common.form.email.option.email.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="confirm"
|
|
||||||
type="password"
|
|
||||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
>
|
>
|
||||||
{t("pages.auth.register.submit")}
|
{t("pages.auth.register.setupNotice")}
|
||||||
</Button>
|
</Alert>
|
||||||
</BaseAuthPage>
|
)}
|
||||||
</FormProvider>
|
<Controller
|
||||||
|
name="firstName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.firstName.label")}
|
||||||
|
placeholder={t("common.form.name.option.firstName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="lastName"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("common.form.name.option.lastName.label")}
|
||||||
|
placeholder={t("common.form.name.option.lastName.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
disabled={!!token}
|
||||||
|
fieldLabel={t("common.form.email.option.email.label")}
|
||||||
|
placeholder={t("common.form.email.option.email.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirm"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t("pages.auth.register.submit")}
|
||||||
|
</Button>
|
||||||
|
</BaseAuthPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
|
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button, TextField } from "@/Components/inputs";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
|
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
|
||||||
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
|
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
|
||||||
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { setAuthState } from "@/Features/Auth/authSlice";
|
import { setAuthState } from "@/Features/Auth/authSlice";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
|
|
||||||
const SetNewPasswordPage = () => {
|
const SetNewPasswordPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
|
|||||||
|
|
||||||
const { schema, defaults } = useSetNewPasswordForm();
|
const { schema, defaults } = useSetNewPasswordForm();
|
||||||
|
|
||||||
const form = useForm<SetNewPasswordFormData>({
|
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
const { handleSubmit, watch } = form;
|
|
||||||
const password = watch("password");
|
const password = watch("password");
|
||||||
const confirm = watch("confirm");
|
const confirm = watch("confirm");
|
||||||
|
|
||||||
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BaseAuthPage
|
||||||
<BaseAuthPage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
title={t("pages.auth.setNewPassword.title")}
|
||||||
title={t("pages.auth.setNewPassword.title")}
|
subtitle={t("pages.auth.setNewPassword.subtitle")}
|
||||||
subtitle={t("pages.auth.setNewPassword.subtitle")}
|
>
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="confirm"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
||||||
|
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stack gap={theme.spacing(4)}>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.length")}
|
||||||
|
variant={getVariant(hasLength)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.special")}
|
||||||
|
variant={getVariant(hasSpecial)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.number")}
|
||||||
|
variant={getVariant(hasNumber)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.uppercase")}
|
||||||
|
variant={getVariant(hasUppercase)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.lowercase")}
|
||||||
|
variant={getVariant(hasLowercase)}
|
||||||
|
/>
|
||||||
|
<BulletPointCheck
|
||||||
|
text={t("pages.auth.common.passwordRules.match")}
|
||||||
|
variant={getVariant(passwordsMatch)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<FormTextField
|
{t("common.buttons.resetPassword")}
|
||||||
name="password"
|
</Button>
|
||||||
type="password"
|
<TextLink
|
||||||
fieldLabel={t("pages.auth.common.form.option.password.label")}
|
alignSelf="center"
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
text={t("pages.auth.forgotPassword.links.login.text")}
|
||||||
/>
|
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
||||||
<FormTextField
|
href="/login"
|
||||||
name="confirm"
|
/>
|
||||||
type="password"
|
</BaseAuthPage>
|
||||||
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
|
|
||||||
placeholder={t("pages.auth.common.form.option.password.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack gap={theme.spacing(4)}>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.length")}
|
|
||||||
variant={getVariant(hasLength)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.special")}
|
|
||||||
variant={getVariant(hasSpecial)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.number")}
|
|
||||||
variant={getVariant(hasNumber)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.uppercase")}
|
|
||||||
variant={getVariant(hasUppercase)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.lowercase")}
|
|
||||||
variant={getVariant(hasLowercase)}
|
|
||||||
/>
|
|
||||||
<BulletPointCheck
|
|
||||||
text={t("pages.auth.common.passwordRules.match")}
|
|
||||||
variant={getVariant(passwordsMatch)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t("common.buttons.resetPassword")}
|
|
||||||
</Button>
|
|
||||||
<TextLink
|
|
||||||
alignSelf="center"
|
|
||||||
text={t("pages.auth.forgotPassword.links.login.text")}
|
|
||||||
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
|
|
||||||
href="/login"
|
|
||||||
/>
|
|
||||||
</BaseAuthPage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
|||||||
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
|
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
import prettyMilliseconds from "pretty-ms";
|
||||||
|
|
||||||
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
|
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
|
||||||
|
|
||||||
@@ -24,5 +24,8 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDuration(durationMs);
|
return prettyMilliseconds(durationMs, {
|
||||||
|
secondsDecimalDigits: 0,
|
||||||
|
millisecondsDecimalDigits: 0,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
@@ -25,11 +25,10 @@ export const InfraDetailsGauges = ({
|
|||||||
<Stack
|
<Stack
|
||||||
direction={isSmall ? "column" : "row"}
|
direction={isSmall ? "column" : "row"}
|
||||||
spacing={theme.spacing(8)}
|
spacing={theme.spacing(8)}
|
||||||
alignItems={"stretch"}
|
alignItems={"center"}
|
||||||
>
|
>
|
||||||
<DetailGauge
|
<DetailGauge
|
||||||
title={t("pages.infrastructure.gauges.memory.title")}
|
title={t("pages.infrastructure.gauges.memory.title")}
|
||||||
maxWidth={260}
|
|
||||||
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
||||||
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
||||||
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
|
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
|
||||||
@@ -38,7 +37,6 @@ export const InfraDetailsGauges = ({
|
|||||||
/>
|
/>
|
||||||
<DetailGauge
|
<DetailGauge
|
||||||
title={t("pages.infrastructure.gauges.cpu.title")}
|
title={t("pages.infrastructure.gauges.cpu.title")}
|
||||||
maxWidth={260}
|
|
||||||
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
||||||
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
||||||
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
||||||
@@ -51,7 +49,6 @@ export const InfraDetailsGauges = ({
|
|||||||
key={disk?.device || 0 + idx}
|
key={disk?.device || 0 + idx}
|
||||||
// title={`Disk ${idx} usage`}
|
// title={`Disk ${idx} usage`}
|
||||||
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
||||||
maxWidth={260}
|
|
||||||
progress={(disk.usage_percent || 0) * 100}
|
progress={(disk.usage_percent || 0) * 100}
|
||||||
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
||||||
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const recentChecks = monitor?.recentChecks ?? [];
|
const latestCheck = monitor?.recentChecks?.[0];
|
||||||
const latestCheck = recentChecks[recentChecks.length - 1];
|
|
||||||
// Get data from latest check
|
// Get data from latest check
|
||||||
const physicalCores = getCores(latestCheck?.cpu?.physical_core);
|
const physicalCores = getCores(latestCheck?.cpu?.physical_core);
|
||||||
const logicalCores = getCores(latestCheck?.cpu?.logical_core);
|
const logicalCores = getCores(latestCheck?.cpu?.logical_core);
|
||||||
@@ -22,7 +21,13 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
|
||||||
const diskTotalBytes =
|
const diskTotalBytes =
|
||||||
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
|
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
|
||||||
const osPlatform = getOsAndPlatform(latestCheck?.host);
|
const os = getOsAndPlatform(latestCheck?.host);
|
||||||
|
|
||||||
|
const platform = latestCheck?.host?.platform ?? undefined;
|
||||||
|
const osPlatform =
|
||||||
|
typeof os === "undefined" && typeof platform === "undefined"
|
||||||
|
? undefined
|
||||||
|
: `${os} ${platform}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ export const TabOverview = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checks = stats?.checks || [];
|
const checks = stats?.checks || [];
|
||||||
const recentChecks = monitor.recentChecks ?? [];
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={theme.spacing(8)}>
|
<Stack gap={theme.spacing(8)}>
|
||||||
<StatusBoxes monitor={monitor} />
|
<StatusBoxes monitor={monitor} />
|
||||||
<InfraDetailsGauges snapshot={recentChecks[recentChecks.length - 1]} />
|
<InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
|
||||||
<InfraDetailsCharts
|
<InfraDetailsCharts
|
||||||
checks={checks}
|
checks={checks}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
|
|||||||
@@ -248,8 +248,7 @@ export const InfraMonitorsTable = ({
|
|||||||
id: "cpu",
|
id: "cpu",
|
||||||
content: t("pages.infrastructure.table.headers.cpu"),
|
content: t("pages.infrastructure.table.headers.cpu"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const recentChecks = row.recentChecks ?? [];
|
const check = row.recentChecks?.[0];
|
||||||
const check = recentChecks[recentChecks.length - 1];
|
|
||||||
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
|
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
|
||||||
return <Gauge progress={cpuUsage} />;
|
return <Gauge progress={cpuUsage} />;
|
||||||
},
|
},
|
||||||
@@ -258,8 +257,7 @@ export const InfraMonitorsTable = ({
|
|||||||
id: "memory",
|
id: "memory",
|
||||||
content: t("pages.infrastructure.table.headers.memory"),
|
content: t("pages.infrastructure.table.headers.memory"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const recentChecks = row.recentChecks ?? [];
|
const check = row.recentChecks?.[0];
|
||||||
const check = recentChecks[recentChecks.length - 1];
|
|
||||||
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
|
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
|
||||||
return <Gauge progress={memoryUsage} />;
|
return <Gauge progress={memoryUsage} />;
|
||||||
},
|
},
|
||||||
@@ -268,8 +266,7 @@ export const InfraMonitorsTable = ({
|
|||||||
id: "disk",
|
id: "disk",
|
||||||
content: t("pages.infrastructure.table.headers.disk"),
|
content: t("pages.infrastructure.table.headers.disk"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const recentChecks = row.recentChecks ?? [];
|
const check = row.recentChecks?.[0];
|
||||||
const check = recentChecks[recentChecks.length - 1];
|
|
||||||
|
|
||||||
const totalDiskUsage = check?.disk?.reduce(
|
const totalDiskUsage = check?.disk?.reduce(
|
||||||
(acc, disk) => acc + (disk?.usage_percent || 0),
|
(acc, disk) => acc + (disk?.usage_percent || 0),
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import Box from "@mui/material/Box";
|
|||||||
import { StatBox } from "@/Components/design-elements";
|
import { StatBox } from "@/Components/design-elements";
|
||||||
|
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||||
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
@@ -18,9 +18,13 @@ export const Stats = ({ diagnostics }: StatsProps) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const eventLoopDelay = diagnostics
|
const eventLoopDelay = diagnostics
|
||||||
? formatMs(diagnostics.eventLoopDelayMs ?? 0)
|
? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
|
||||||
|
millisecondsDecimalDigits: 2,
|
||||||
|
})
|
||||||
|
: PLACEHOLDER;
|
||||||
|
const uptime = diagnostics
|
||||||
|
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
|
||||||
: PLACEHOLDER;
|
: PLACEHOLDER;
|
||||||
const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER;
|
|
||||||
const usedHeap = diagnostics
|
const usedHeap = diagnostics
|
||||||
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
|
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
|
||||||
: PLACEHOLDER;
|
: PLACEHOLDER;
|
||||||
@@ -33,10 +37,10 @@ export const Stats = ({ diagnostics }: StatsProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
display="grid"
|
|
||||||
gap={theme.spacing(8)}
|
|
||||||
sx={{
|
sx={{
|
||||||
|
display: "grid",
|
||||||
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
|
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
|
||||||
|
gap: theme.spacing(8),
|
||||||
"& > *": { width: "100% !important" },
|
"& > *": { width: "100% !important" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Table, Pagination } from "@/Components/design-elements";
|
import { Table, Pagination } from "@/Components/design-elements";
|
||||||
import { Typography, useTheme } from "@mui/material";
|
import { Typography, useTheme } from "@mui/material";
|
||||||
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
|
import prettyMilliseconds from "pretty-ms";
|
||||||
|
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
|
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
|
||||||
@@ -83,7 +84,7 @@ export const TableJobs = ({
|
|||||||
id: "interval",
|
id: "interval",
|
||||||
content: t("common.table.headers.interval"),
|
content: t("common.table.headers.interval"),
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<Typography sx={cellSx}>{formatDuration(row.monitorInterval ?? 0)}</Typography>
|
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Pagination } from "@/Components/design-elements/Table";
|
|||||||
import { ActionsMenu } from "@/Components/actions-menu";
|
import { ActionsMenu } from "@/Components/actions-menu";
|
||||||
import { DialogInput } from "@/Components/inputs/Dialog";
|
import { DialogInput } from "@/Components/inputs/Dialog";
|
||||||
|
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
@@ -17,7 +18,6 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDelete, usePatch } from "@/Hooks/UseApi";
|
import { useDelete, usePatch } from "@/Hooks/UseApi";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
|
|
||||||
interface MaintenanceWindowTableProps {
|
interface MaintenanceWindowTableProps {
|
||||||
maintenanceWindows: MaintenanceWindow[];
|
maintenanceWindows: MaintenanceWindow[];
|
||||||
@@ -51,7 +51,7 @@ const getTimeToNextWindow = (
|
|||||||
|
|
||||||
// Window is in the future
|
// Window is in the future
|
||||||
if (start.isAfter(now)) {
|
if (start.isAfter(now)) {
|
||||||
return formatDuration(start.diff(now));
|
return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return "N/A";
|
return "N/A";
|
||||||
@@ -152,7 +152,9 @@ export const MaintenanceWindowTable = ({
|
|||||||
id: "repeat",
|
id: "repeat",
|
||||||
content: t("pages.maintenanceWindow.table.headers.repeat"),
|
content: t("pages.maintenanceWindow.table.headers.repeat"),
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat),
|
row.repeat === 0
|
||||||
|
? t("common.labels.na")
|
||||||
|
: prettyMilliseconds(row.repeat, { verbose: true }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { logger } from "@/Utils/logger";
|
import { logger } from "@/Utils/logger";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import {
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
TimePicker,
|
||||||
|
Button,
|
||||||
|
Autocomplete,
|
||||||
|
} from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,17 +25,10 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
|||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
import { Trash2 } from "lucide-react";
|
||||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
|
||||||
import {
|
|
||||||
FormDatePickerField,
|
|
||||||
FormTimePickerField,
|
|
||||||
} from "@/Components/inputs/forms/FormDateTimePickerField";
|
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
|
||||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
|
||||||
|
|
||||||
const CreateMaintenanceWindowPage = () => {
|
const CreateMaintenanceWindowPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -54,7 +57,7 @@ const CreateMaintenanceWindowPage = () => {
|
|||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, trigger } = form;
|
const { control, handleSubmit, trigger } = form;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingMaintenanceWindow) {
|
if (existingMaintenanceWindow) {
|
||||||
form.reset(defaults);
|
form.reset(defaults);
|
||||||
@@ -111,117 +114,255 @@ const CreateMaintenanceWindowPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BasePage
|
||||||
<BasePage
|
component={"form"}
|
||||||
component={"form"}
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
onSubmit={handleSubmit(onSubmit, onError)}
|
>
|
||||||
>
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.maintenanceWindow.form.general.title")}
|
||||||
title={t("pages.maintenanceWindow.form.general.title")}
|
subtitle={t("pages.maintenanceWindow.form.general.description")}
|
||||||
subtitle={t("pages.maintenanceWindow.form.general.description")}
|
rightContent={
|
||||||
rightContent={
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
<Controller
|
||||||
<FormTextField
|
name="name"
|
||||||
name="name"
|
control={control}
|
||||||
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
|
defaultValue={defaults.name}
|
||||||
placeholder={t(
|
render={({ field, fieldState }) => (
|
||||||
"pages.maintenanceWindow.form.general.option.name.placeholder"
|
<TextField
|
||||||
)}
|
{...field}
|
||||||
/>
|
type="text"
|
||||||
<FormSelectField
|
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
|
||||||
name="repeat"
|
placeholder={t(
|
||||||
fieldLabel={t("pages.maintenanceWindow.form.general.option.repeat.label")}
|
"pages.maintenanceWindow.form.general.option.name.placeholder"
|
||||||
options={repeatOptions.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.maintenanceWindow.form.startDate.title")}
|
|
||||||
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<FormDatePickerField
|
|
||||||
name="startDate"
|
|
||||||
fieldLabel={t(
|
|
||||||
"pages.maintenanceWindow.form.startDate.option.startDate.label"
|
|
||||||
)}
|
|
||||||
onValueChange={() => trigger("startDate")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.maintenanceWindow.form.startTime.title")}
|
|
||||||
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<FormTimePickerField
|
|
||||||
name="startTime"
|
|
||||||
fieldLabel={t(
|
|
||||||
"pages.maintenanceWindow.form.startTime.option.startTime.label"
|
|
||||||
)}
|
|
||||||
onValueChange={() => trigger("startDate")}
|
|
||||||
/>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="flex-start"
|
|
||||||
spacing={theme.spacing(LAYOUT.MD)}
|
|
||||||
>
|
|
||||||
<FormNumberField
|
|
||||||
name="duration"
|
|
||||||
fieldLabel={t(
|
|
||||||
"pages.maintenanceWindow.form.startTime.option.duration.label"
|
|
||||||
)}
|
)}
|
||||||
sx={{ width: 120 }}
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
/>
|
/>
|
||||||
<FormSelectField
|
|
||||||
name="durationUnit"
|
|
||||||
fieldLabel={" "}
|
|
||||||
options={durationUnitOptions.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
|
|
||||||
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
|
|
||||||
rightContent={
|
|
||||||
<FormMultiSelectField
|
|
||||||
name="monitors"
|
|
||||||
fieldLabel={t(
|
|
||||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
|
||||||
)}
|
)}
|
||||||
placeholder={t(
|
|
||||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
|
||||||
)}
|
|
||||||
options={monitors ?? []}
|
|
||||||
/>
|
/>
|
||||||
}
|
<Controller
|
||||||
/>
|
name="repeat"
|
||||||
<Stack
|
control={control}
|
||||||
direction="row"
|
defaultValue={defaults.repeat}
|
||||||
justifyContent="flex-end"
|
render={({ field, fieldState }) => (
|
||||||
spacing={theme.spacing(SPACING.LG)}
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.maintenanceWindow.form.general.option.repeat.label"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
onChange={field.onChange}
|
||||||
|
>
|
||||||
|
{repeatOptions.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.id}
|
||||||
|
value={option.id}
|
||||||
|
>
|
||||||
|
<Typography>{option.name}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.maintenanceWindow.form.startDate.title")}
|
||||||
|
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Controller
|
||||||
|
name="startDate"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.startDate}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<DatePicker
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.maintenanceWindow.form.startDate.option.startDate.label"
|
||||||
|
)}
|
||||||
|
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
|
||||||
|
onChange={(date) => {
|
||||||
|
field.onChange(date ? date.format("YYYY-MM-DD") : "");
|
||||||
|
trigger("startDate");
|
||||||
|
}}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.maintenanceWindow.form.startTime.title")}
|
||||||
|
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Controller
|
||||||
|
name="startTime"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.startTime}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TimePicker
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.maintenanceWindow.form.startTime.option.startTime.label"
|
||||||
|
)}
|
||||||
|
value={field.value ? dayjs(field.value, "HH:mm") : null}
|
||||||
|
onChange={(time) => {
|
||||||
|
field.onChange(time ? time.format("HH:mm") : "");
|
||||||
|
trigger("startDate");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="flex-start"
|
||||||
|
spacing={theme.spacing(LAYOUT.MD)}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="duration"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.duration}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.maintenanceWindow.form.startTime.option.duration.label"
|
||||||
|
)}
|
||||||
|
value={field.value === 0 ? "" : field.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
field.onChange(val === "" ? 0 : Number(val));
|
||||||
|
}}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
sx={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="durationUnit"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.durationUnit}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
fieldLabel=" "
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
sx={{ minWidth: 120 }}
|
||||||
|
>
|
||||||
|
{durationUnitOptions.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.id}
|
||||||
|
value={option.id}
|
||||||
|
>
|
||||||
|
<Typography>{option.name}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
|
||||||
|
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="monitors"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.monitors}
|
||||||
|
render={({ field, fieldState }) => {
|
||||||
|
const monitorsList = monitors ?? [];
|
||||||
|
const selectedMonitors = field.value
|
||||||
|
.map((id: string) => monitorsList.find((m) => m.id === id))
|
||||||
|
.filter((m): m is Monitor => m !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={monitorsList}
|
||||||
|
getOptionLabel={(option: Monitor) => option.name}
|
||||||
|
value={selectedMonitors}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||||
|
}}
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||||
|
)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={
|
||||||
|
selectedMonitors.length === 0
|
||||||
|
? t(
|
||||||
|
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||||
|
)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{selectedMonitors.length > 0 && (
|
||||||
|
<Stack>
|
||||||
|
{selectedMonitors.map((monitor) => (
|
||||||
|
<Stack
|
||||||
|
key={monitor.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
padding={theme.spacing(LAYOUT.XS)}
|
||||||
|
marginTop={theme.spacing(SPACING.LG)}
|
||||||
|
borderRadius={1}
|
||||||
|
sx={{
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography flexGrow={1}>{monitor.name}</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(
|
||||||
|
field.value.filter((id: string) => id !== monitor.id)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
aria-label="Remove monitor"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
spacing={theme.spacing(SPACING.LG)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
>
|
>
|
||||||
<Button
|
{t("common.buttons.save")}
|
||||||
loading={isLoading}
|
</Button>
|
||||||
type="submit"
|
</Stack>
|
||||||
variant="contained"
|
</BasePage>
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</BasePage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { TextField, Select, Button } from "@/Components/inputs";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||||
import { useNotificationForm } from "@/Hooks/useNotificationForm";
|
import { useNotificationForm } from "@/Hooks/useNotificationForm";
|
||||||
@@ -14,8 +16,6 @@ import type { NotificationFormData } from "@/Validation/notifications";
|
|||||||
import type { Notification } from "@/Types/Notification";
|
import type { Notification } from "@/Types/Notification";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NotificationChannels } from "@/Types/Notification";
|
import { NotificationChannels } from "@/Types/Notification";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
|
||||||
|
|
||||||
const NotificationsCreatePage = () => {
|
const NotificationsCreatePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -39,7 +39,7 @@ const NotificationsCreatePage = () => {
|
|||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
|
const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset(defaults);
|
reset(defaults);
|
||||||
@@ -93,195 +93,384 @@ const NotificationsCreatePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BasePage
|
||||||
<BasePage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.notificationName.title")}
|
||||||
|
subtitle={t("pages.notifications.form.notificationName.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="notificationName"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.notificationName}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
|
||||||
|
placeholder={t("pages.notifications.form.notificationName.placeholder")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.type.title")}
|
||||||
|
subtitle={t("pages.notifications.form.type.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.type}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
fieldLabel={t("pages.notifications.form.type.optionType")}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
onChange={field.onChange}
|
||||||
|
>
|
||||||
|
{NotificationChannels.map((type: string) => (
|
||||||
|
<MenuItem
|
||||||
|
key={type}
|
||||||
|
value={type}
|
||||||
|
>
|
||||||
|
<Typography textTransform="capitalize">{type}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{watchedType !== "matrix" &&
|
||||||
|
watchedType !== "telegram" &&
|
||||||
|
watchedType !== "pushover" &&
|
||||||
|
watchedType !== "twilio" &&
|
||||||
|
watchedType !== "ntfy" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={addressConfig.title}
|
||||||
|
subtitle={addressConfig.description}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="address"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={addressConfig.fieldLabel}
|
||||||
|
placeholder={addressConfig.placeholder}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{watchedType === "ntfy" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.ntfy.title")}
|
||||||
|
subtitle={t("pages.notifications.form.ntfy.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="address"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
|
||||||
|
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="topic"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"topic" in defaults ? defaults.topic : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
|
||||||
|
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{watchedType === "telegram" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.telegram.title")}
|
||||||
|
subtitle={t("pages.notifications.form.telegram.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="accessToken"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.telegram.placeholderBotToken"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="address"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
|
||||||
|
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{watchedType === "pushover" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.pushover.title")}
|
||||||
|
subtitle={t("pages.notifications.form.pushover.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="accessToken"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.pushover.placeholderAppToken"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="address"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"address" in defaults ? defaults.address : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.pushover.placeholderUserKey"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{watchedType === "twilio" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.twilio.title")}
|
||||||
|
subtitle={t("pages.notifications.form.twilio.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="accountSid"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.twilio.placeholderAccountSid"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="accessToken"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.twilio.placeholderAuthToken"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="twilioPhoneNumber"
|
||||||
|
control={control}
|
||||||
|
defaultValue={
|
||||||
|
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
|
||||||
|
}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.twilio.placeholderFromNumber"
|
||||||
|
)}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="phone"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"phone" in defaults ? defaults.phone : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
|
||||||
|
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{watchedType === "matrix" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.matrix.title")}
|
||||||
|
subtitle={t("pages.notifications.form.matrix.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<Controller
|
||||||
|
name="homeserverUrl"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
|
||||||
|
placeholder={t("pages.notifications.form.homeServer.placeholder")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="roomId"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
|
||||||
|
placeholder={t("pages.notifications.form.roomId.placeholder")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="accessToken"
|
||||||
|
control={control}
|
||||||
|
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.notifications.form.accessToken.optionAccessToken"
|
||||||
|
)}
|
||||||
|
placeholder={t("pages.notifications.form.accessToken.placeholder")}
|
||||||
|
fullWidth
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
spacing={theme.spacing(2)}
|
||||||
>
|
>
|
||||||
<ConfigBox
|
<Button
|
||||||
title={t("pages.notifications.form.notificationName.title")}
|
variant="contained"
|
||||||
subtitle={t("pages.notifications.form.notificationName.description")}
|
color="primary"
|
||||||
rightContent={
|
onClick={handleTest}
|
||||||
<FormTextField
|
loading={isTesting}
|
||||||
name="notificationName"
|
|
||||||
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
|
|
||||||
placeholder={t("pages.notifications.form.notificationName.placeholder")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.notifications.form.type.title")}
|
|
||||||
subtitle={t("pages.notifications.form.type.description")}
|
|
||||||
rightContent={
|
|
||||||
<FormSelectField
|
|
||||||
name="type"
|
|
||||||
fieldLabel={t("pages.notifications.form.type.optionType")}
|
|
||||||
options={NotificationChannels.map((channel) => ({
|
|
||||||
value: channel,
|
|
||||||
label: t(`pages.notifications.form.type.value.${channel}`),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{watchedType !== "matrix" &&
|
|
||||||
watchedType !== "telegram" &&
|
|
||||||
watchedType !== "pushover" &&
|
|
||||||
watchedType !== "twilio" &&
|
|
||||||
watchedType !== "ntfy" && (
|
|
||||||
<ConfigBox
|
|
||||||
title={addressConfig.title}
|
|
||||||
subtitle={addressConfig.description}
|
|
||||||
rightContent={
|
|
||||||
<FormTextField
|
|
||||||
name="address"
|
|
||||||
fieldLabel={addressConfig.fieldLabel}
|
|
||||||
placeholder={addressConfig.placeholder}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{watchedType === "ntfy" && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.notifications.form.ntfy.title")}
|
|
||||||
subtitle={t("pages.notifications.form.ntfy.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(8)}>
|
|
||||||
<FormTextField
|
|
||||||
name="address"
|
|
||||||
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
|
|
||||||
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="topic"
|
|
||||||
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
|
|
||||||
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{watchedType === "telegram" && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.notifications.form.telegram.title")}
|
|
||||||
subtitle={t("pages.notifications.form.telegram.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(8)}>
|
|
||||||
<FormTextField
|
|
||||||
name="accessToken"
|
|
||||||
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
|
|
||||||
placeholder={t("pages.notifications.form.telegram.placeholderBotToken")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="address"
|
|
||||||
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
|
|
||||||
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{watchedType === "pushover" && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.notifications.form.pushover.title")}
|
|
||||||
subtitle={t("pages.notifications.form.pushover.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(8)}>
|
|
||||||
<FormTextField
|
|
||||||
name="accessToken"
|
|
||||||
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
|
|
||||||
placeholder={t("pages.notifications.form.pushover.placeholderAppToken")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="address"
|
|
||||||
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
|
|
||||||
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{watchedType === "twilio" && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.notifications.form.twilio.title")}
|
|
||||||
subtitle={t("pages.notifications.form.twilio.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(8)}>
|
|
||||||
<FormTextField
|
|
||||||
name="accountSid"
|
|
||||||
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
|
|
||||||
placeholder={t("pages.notifications.form.twilio.placeholderAccountSid")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="accessToken"
|
|
||||||
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
|
|
||||||
placeholder={t("pages.notifications.form.twilio.placeholderAuthToken")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="twilioPhoneNumber"
|
|
||||||
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
|
|
||||||
placeholder={t("pages.notifications.form.twilio.placeholderFromNumber")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormTextField
|
|
||||||
name="phone"
|
|
||||||
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
|
|
||||||
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{watchedType === "matrix" && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.notifications.form.matrix.title")}
|
|
||||||
subtitle={t("pages.notifications.form.matrix.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(8)}>
|
|
||||||
<FormTextField
|
|
||||||
name="homeserverUrl"
|
|
||||||
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
|
|
||||||
placeholder={t("pages.notifications.form.homeServer.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="roomId"
|
|
||||||
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
|
|
||||||
placeholder={t("pages.notifications.form.roomId.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="accessToken"
|
|
||||||
fieldLabel={t("pages.notifications.form.accessToken.optionAccessToken")}
|
|
||||||
placeholder={t("pages.notifications.form.accessToken.placeholder")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
spacing={theme.spacing(2)}
|
|
||||||
>
|
>
|
||||||
<Button
|
{t("common.buttons.test")}
|
||||||
variant="contained"
|
</Button>
|
||||||
color="primary"
|
<Button
|
||||||
onClick={handleTest}
|
loading={isSubmitting || isPatching}
|
||||||
loading={isTesting}
|
type="submit"
|
||||||
>
|
variant="contained"
|
||||||
{t("common.buttons.test")}
|
color="primary"
|
||||||
</Button>
|
>
|
||||||
<Button
|
{t("common.buttons.save")}
|
||||||
loading={isSubmitting || isPatching}
|
</Button>
|
||||||
type="submit"
|
</Stack>
|
||||||
variant="contained"
|
</BasePage>
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</BasePage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ const PageSpeedDetails = () => {
|
|||||||
const monitor = monitorData?.monitorData?.monitor;
|
const monitor = monitorData?.monitorData?.monitor;
|
||||||
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
|
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
|
||||||
const monitorStats = monitorData?.monitorStats || null;
|
const monitorStats = monitorData?.monitorStats || null;
|
||||||
const recentChecks = monitor?.recentChecks ?? [];
|
|
||||||
const latestCheck = recentChecks[recentChecks.length - 1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BasePage
|
<BasePage
|
||||||
@@ -77,8 +75,8 @@ const PageSpeedDetails = () => {
|
|||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
gap={theme.spacing(10)}
|
gap={theme.spacing(10)}
|
||||||
>
|
>
|
||||||
<PiePageSpeed latestCheck={latestCheck} />
|
<PiePageSpeed latestCheck={monitor?.recentChecks?.[0]} />
|
||||||
<PiePageSpeedLegend latestCheck={latestCheck} />
|
<PiePageSpeedLegend latestCheck={monitor?.recentChecks?.[0]} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</BasePage>
|
</BasePage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
|
||||||
import { Table } from "@/Components/design-elements";
|
|
||||||
import { Pagination } from "@/Components/design-elements/Table";
|
|
||||||
import { useClientPagination } from "@/Hooks/useClientPagination";
|
|
||||||
|
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
|
|
||||||
interface ProxiesTableProps {
|
|
||||||
proxies: ProxyResponse[];
|
|
||||||
setSelectedProxy: Function;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const { pagedRows, paginationProps } = useClientPagination(proxies);
|
|
||||||
|
|
||||||
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
label: t("pages.common.monitors.actions.configure"),
|
|
||||||
action: () => {
|
|
||||||
navigate(`/proxies/configure/${proxy.id}`);
|
|
||||||
},
|
|
||||||
closeMenu: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
label: (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.common.monitors.actions.delete")}
|
|
||||||
</Typography>
|
|
||||||
),
|
|
||||||
action: async () => {
|
|
||||||
setSelectedProxy(proxy);
|
|
||||||
},
|
|
||||||
closeMenu: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHeaders = () => {
|
|
||||||
const headers: Header<ProxyResponse>[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
content: t("common.table.headers.name"),
|
|
||||||
render: (row) => {
|
|
||||||
return <Typography>{row?.name}</Typography>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "protocol",
|
|
||||||
content: t("pages.proxies.table.headers.protocol"),
|
|
||||||
render: (row) => {
|
|
||||||
return <Typography>{row?.protocol}</Typography>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "host",
|
|
||||||
content: t("pages.proxies.table.headers.host"),
|
|
||||||
render: (row) => {
|
|
||||||
return <Typography>{row?.host}</Typography>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "port",
|
|
||||||
content: t("pages.proxies.table.headers.port"),
|
|
||||||
render: (row) => {
|
|
||||||
return <Typography>{row?.port}</Typography>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
content: t("common.table.headers.actions"),
|
|
||||||
render: (row) => {
|
|
||||||
return <ActionsMenu items={getActions(row)} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Table
|
|
||||||
headers={getHeaders()}
|
|
||||||
data={pagedRows}
|
|
||||||
onRowClick={(row) => {
|
|
||||||
navigate(`/proxies/configure/${row.id}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{proxies.length > 0 && <Pagination {...paginationProps} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { Button, FieldLabel } from "@/Components/inputs";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { mutate } from "swr";
|
|
||||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
|
||||||
import type { ProxyFormData } from "@/Validation/proxy";
|
|
||||||
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useProxiesForm } from "@/Hooks/useProxiesForm";
|
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
|
||||||
const ProxiesCreatePage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const theme = useTheme();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { proxyId } = useParams<{ proxyId: string }>();
|
|
||||||
const isEditMode = Boolean(proxyId);
|
|
||||||
|
|
||||||
const { data: existingProxy } = useGet<ProxyResponse>(
|
|
||||||
isEditMode ? `/proxies/${proxyId}` : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
|
|
||||||
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
|
|
||||||
|
|
||||||
const { schema, defaults } = useProxiesForm({ data: existingProxy });
|
|
||||||
|
|
||||||
const form = useForm<ProxyFormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: defaults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { reset, handleSubmit } = form;
|
|
||||||
|
|
||||||
const isPasswordSet = existingProxy?.hasPassword ?? false;
|
|
||||||
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
|
|
||||||
|
|
||||||
const handleResetPassword = () => {
|
|
||||||
form.setValue("password", "");
|
|
||||||
setPasswordHasBeenReset(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isUsernameSet = Boolean(existingProxy?.username);
|
|
||||||
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
|
|
||||||
|
|
||||||
const handleResetUsername = () => {
|
|
||||||
form.setValue("username", "");
|
|
||||||
setUsernameHasBeenReset(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const protocolOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
ProxyProtocols.map((protocol) => ({
|
|
||||||
value: protocol,
|
|
||||||
label: protocol,
|
|
||||||
})),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset(defaults);
|
|
||||||
}, [defaults, reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: ProxyFormData) => {
|
|
||||||
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
|
|
||||||
{
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
// Don't send password/username if one is set and the user hasn't clicked reset
|
|
||||||
if (isPasswordSet && !passwordHasBeenReset) {
|
|
||||||
delete payload.password;
|
|
||||||
} else if (passwordHasBeenReset && !payload.password) {
|
|
||||||
payload.clearPassword = true;
|
|
||||||
}
|
|
||||||
if (isUsernameSet && !usernameHasBeenReset) {
|
|
||||||
delete payload.username;
|
|
||||||
} else if (usernameHasBeenReset && !payload.username) {
|
|
||||||
payload.clearUsername = true;
|
|
||||||
}
|
|
||||||
const result = isEditMode
|
|
||||||
? await patch(`/proxies/${proxyId}`, payload)
|
|
||||||
: await post("/proxies", payload);
|
|
||||||
if (result?.success) {
|
|
||||||
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
|
|
||||||
navigate("/proxies");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<BasePage
|
|
||||||
component="form"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.proxies.form.name.title")}
|
|
||||||
subtitle={t("pages.proxies.form.name.description")}
|
|
||||||
rightContent={
|
|
||||||
<FormTextField
|
|
||||||
name="name"
|
|
||||||
fieldLabel={t("pages.proxies.form.name.optionName")}
|
|
||||||
placeholder={t("pages.proxies.form.name.placeholder")}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.proxies.form.server.title")}
|
|
||||||
subtitle={t("pages.proxies.form.server.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<FormSelectField
|
|
||||||
name="protocol"
|
|
||||||
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
|
|
||||||
options={protocolOptions}
|
|
||||||
/>
|
|
||||||
<FormTextField
|
|
||||||
name="host"
|
|
||||||
fieldLabel={t("pages.proxies.form.server.option.host.label")}
|
|
||||||
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
|
|
||||||
/>
|
|
||||||
<FormNumberField
|
|
||||||
name="port"
|
|
||||||
fieldLabel={t("pages.proxies.form.server.option.port.label")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.proxies.form.auth.title")}
|
|
||||||
subtitle={t("pages.proxies.form.auth.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
|
||||||
{(isUsernameSet === false || usernameHasBeenReset === true) && (
|
|
||||||
<FormTextField
|
|
||||||
name="username"
|
|
||||||
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
|
|
||||||
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUsernameSet === true && usernameHasBeenReset === false && (
|
|
||||||
<Box>
|
|
||||||
<FieldLabel>
|
|
||||||
{t("pages.proxies.form.auth.option.username.labelSet")}
|
|
||||||
</FieldLabel>
|
|
||||||
<Button
|
|
||||||
onClick={handleResetUsername}
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{t("common.buttons.reset")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isPasswordSet === false || passwordHasBeenReset === true) && (
|
|
||||||
<FormTextField
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isPasswordSet === true && passwordHasBeenReset === false && (
|
|
||||||
<Box>
|
|
||||||
<FieldLabel>
|
|
||||||
{t("pages.proxies.form.auth.option.password.labelSet")}
|
|
||||||
</FieldLabel>
|
|
||||||
<Button
|
|
||||||
onClick={handleResetPassword}
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{t("common.buttons.reset")}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
spacing={theme.spacing(2)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
loading={isSubmitting || isPatching}
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</BasePage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProxiesCreatePage;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { BasePageWithStates } from "@/Components/design-elements";
|
|
||||||
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
|
|
||||||
import { Dialog } from "@/Components/inputs";
|
|
||||||
import { HeaderCreate } from "@/Components/common";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
|
||||||
|
|
||||||
const ProxiesPage = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isAdmin = useIsAdmin();
|
|
||||||
|
|
||||||
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
|
|
||||||
const isDialogOpen = Boolean(selectedProxy);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: proxies,
|
|
||||||
isLoading,
|
|
||||||
isValidating,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
|
|
||||||
|
|
||||||
const { deleteFn, loading: isDeleting } = useDelete();
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!selectedProxy) return;
|
|
||||||
await deleteFn(`/proxies/${selectedProxy.id}`);
|
|
||||||
setSelectedProxy(null);
|
|
||||||
refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setSelectedProxy(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BasePageWithStates
|
|
||||||
headerKey="proxies"
|
|
||||||
page={t("pages.proxies.fallback.title")}
|
|
||||||
description={t("pages.proxies.fallback.description")}
|
|
||||||
loading={isLoading || isValidating}
|
|
||||||
error={!!error}
|
|
||||||
totalCount={proxies?.length ?? 0}
|
|
||||||
actionButtonText={t("pages.proxies.fallback.actionButton")}
|
|
||||||
actionLink="/proxies/create"
|
|
||||||
>
|
|
||||||
<HeaderCreate
|
|
||||||
path="/proxies/create"
|
|
||||||
isLoading={isLoading || isValidating}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
<ProxiesTable
|
|
||||||
proxies={proxies ?? []}
|
|
||||||
setSelectedProxy={setSelectedProxy}
|
|
||||||
/>
|
|
||||||
<Dialog
|
|
||||||
open={isDialogOpen}
|
|
||||||
title={t("common.dialogs.delete.title")}
|
|
||||||
content={t("common.dialogs.delete.description")}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
loading={isDeleting}
|
|
||||||
/>
|
|
||||||
</BasePageWithStates>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProxiesPage;
|
|
||||||
@@ -13,12 +13,13 @@ const generateDummyChecks = (): CheckSnapshot[] => {
|
|||||||
const checks: CheckSnapshot[] = [];
|
const checks: CheckSnapshot[] = [];
|
||||||
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
||||||
const isUp = Math.random() > 0.1;
|
const isUp = Math.random() > 0.1;
|
||||||
const responseTime = Math.floor(Math.random() * 800) + 20;
|
const responseTime = Math.floor(Math.random() * 80) + 20;
|
||||||
checks.push({
|
checks.push({
|
||||||
id: `dummy-${i}`,
|
id: `dummy-${i}`,
|
||||||
status: isUp,
|
status: isUp,
|
||||||
statusCode: isUp ? 200 : 500,
|
statusCode: isUp ? 200 : 500,
|
||||||
responseTime: responseTime,
|
responseTime: responseTime,
|
||||||
|
originalResponseTime: responseTime,
|
||||||
message: "",
|
message: "",
|
||||||
createdAt: new Date(Date.now() - i * 60000).toISOString(),
|
createdAt: new Date(Date.now() - i * 60000).toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
+777
-646
File diff suppressed because it is too large.
Load diff
@@ -1,81 +1,56 @@
|
|||||||
import {
|
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
|
||||||
BasePage,
|
|
||||||
ColoredLabel,
|
|
||||||
ConfigBox,
|
|
||||||
StepProgress,
|
|
||||||
} from "@/Components/design-elements";
|
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { logger } from "@/Utils/logger";
|
import { logger } from "@/Utils/logger";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { ImageUpload, Button, Dialog } from "@/Components/inputs";
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import { Trash2, GripVertical } from "lucide-react";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import type { DropResult } from "@hello-pangea/dnd";
|
||||||
|
import {
|
||||||
|
ImageUpload,
|
||||||
|
SwitchComponent,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
ColorInput,
|
||||||
|
} from "@/Components/inputs";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm, FormProvider, useController } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
|
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
|
||||||
import type { StatusPageFormData } from "@/Validation/statusPage";
|
import type { StatusPageFormData } from "@/Validation/statusPage";
|
||||||
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
|
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
|
||||||
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import { SelectableMonitorTypes } from "@/Types/Monitor";
|
|
||||||
import type { Tag } from "@/Types/Tag";
|
|
||||||
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
|
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
|
||||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||||
import { timezoneOptions } from "@/Utils/timezoneOptions";
|
import timezones from "@/Utils/timezones.json";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
|
import axios from "axios";
|
||||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||||
import { ThemePicker } from "./Components/ThemePicker";
|
import { ThemePicker } from "./Components/ThemePicker";
|
||||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
|
||||||
import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField";
|
|
||||||
import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField";
|
|
||||||
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
|
|
||||||
|
|
||||||
const ThemePickerField = () => {
|
|
||||||
const { field: themeField } = useController<StatusPageFormData, "theme">({
|
|
||||||
name: "theme",
|
|
||||||
});
|
|
||||||
const { field: modeField } = useController<StatusPageFormData, "themeMode">({
|
|
||||||
name: "themeMode",
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<ThemePicker
|
|
||||||
theme={themeField.value ?? "refined"}
|
|
||||||
themeMode={modeField.value ?? "auto"}
|
|
||||||
onThemeChange={themeField.onChange}
|
|
||||||
onThemeModeChange={modeField.onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const LogoUploadField = () => {
|
|
||||||
const { field } = useController<StatusPageFormData, "logo">({ name: "logo" });
|
|
||||||
return (
|
|
||||||
<ImageUpload
|
|
||||||
src={field.value?.data}
|
|
||||||
onChange={(file) => {
|
|
||||||
field.onChange(
|
|
||||||
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Derived from SelectableMonitorTypes so a newly supported monitor type is
|
|
||||||
// offered here automatically; the previous hardcoded list silently omitted DNS.
|
|
||||||
// "hardware" is appended because infrastructure monitors are created on their
|
|
||||||
// own page and so are absent from the selectable set.
|
|
||||||
const monitorsUrl = (() => {
|
const monitorsUrl = (() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
[...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type));
|
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
|
||||||
|
(type) => params.append("type", type)
|
||||||
|
);
|
||||||
return `/monitors/team?${params.toString()}`;
|
return `/monitors/team?${params.toString()}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
interface TimezoneOption {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const buildStatusPageKey = (slug: string | null | undefined) =>
|
const buildStatusPageKey = (slug: string | null | undefined) =>
|
||||||
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
|
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
|
||||||
|
|
||||||
@@ -92,9 +67,7 @@ const CreateStatusPage = () => {
|
|||||||
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
|
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
|
||||||
|
|
||||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||||
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
|
const monitors = monitorsResponse ?? [];
|
||||||
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
|
|
||||||
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
|
|
||||||
|
|
||||||
const { post, loading: isSubmittingPost } = usePost();
|
const { post, loading: isSubmittingPost } = usePost();
|
||||||
const { put, loading: isSubmittingPut } = usePut();
|
const { put, loading: isSubmittingPut } = usePut();
|
||||||
@@ -114,7 +87,7 @@ const CreateStatusPage = () => {
|
|||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { reset, handleSubmit, trigger } = form;
|
const { control, reset, handleSubmit, trigger } = form;
|
||||||
|
|
||||||
// Reset form when defaults change (from fetched data)
|
// Reset form when defaults change (from fetched data)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,7 +108,7 @@ const CreateStatusPage = () => {
|
|||||||
};
|
};
|
||||||
const handleBack = () => setCurrentStep((step) => step - 1);
|
const handleBack = () => setCurrentStep((step) => step - 1);
|
||||||
|
|
||||||
const watchedMonitorIds: string[] = form.watch("monitors");
|
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
|
||||||
const computedTypes: MonitorDisplayType[] = useMemo(() => {
|
const computedTypes: MonitorDisplayType[] = useMemo(() => {
|
||||||
const selectedMonitors = (watchedMonitorIds ?? [])
|
const selectedMonitors = (watchedMonitorIds ?? [])
|
||||||
.map((id) => monitors.find((m) => m.id === id))
|
.map((id) => monitors.find((m) => m.id === id))
|
||||||
@@ -151,7 +124,7 @@ const CreateStatusPage = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.setValue("type", computedTypes);
|
form.setValue("type", computedTypes);
|
||||||
}, [computedTypes, form]);
|
}, [computedTypes]);
|
||||||
|
|
||||||
const onError = (errors: any) => {
|
const onError = (errors: any) => {
|
||||||
logger.debug("Status page validation errors", errors);
|
logger.debug("Status page validation errors", errors);
|
||||||
@@ -205,8 +178,18 @@ const CreateStatusPage = () => {
|
|||||||
if (data.logo === null) {
|
if (data.logo === null) {
|
||||||
// Signal to remove the logo
|
// Signal to remove the logo
|
||||||
fd.append("removeLogo", "true");
|
fd.append("removeLogo", "true");
|
||||||
} else if (data.logo?.file) {
|
} else if (data.logo?.data && data.logo.data !== "") {
|
||||||
fd.append("logo", data.logo.file);
|
if (data.logo.data.startsWith("blob:")) {
|
||||||
|
try {
|
||||||
|
const imageResult = await axios.get(data.logo.data, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
fd.append("logo", imageResult.data);
|
||||||
|
URL.revokeObjectURL(data.logo.data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
@@ -233,249 +216,470 @@ const CreateStatusPage = () => {
|
|||||||
return <BasePage>Loading...</BasePage>;
|
return <BasePage>Loading...</BasePage>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BasePage
|
||||||
<BasePage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit, onError)}
|
||||||
onSubmit={handleSubmit(onSubmit, onError)}
|
>
|
||||||
>
|
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
|
||||||
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
|
{isCreate && (
|
||||||
{isCreate && (
|
<StepProgress
|
||||||
<StepProgress
|
steps={totalSteps}
|
||||||
steps={totalSteps}
|
current={currentStep}
|
||||||
current={currentStep}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{showStep(0) && (
|
||||||
{showStep(0) && (
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.statusPages.form.access.title")}
|
||||||
title={t("pages.statusPages.form.access.title")}
|
subtitle={t("pages.statusPages.form.access.description")}
|
||||||
subtitle={t("pages.statusPages.form.access.description")}
|
rightContent={
|
||||||
rightContent={
|
<Stack
|
||||||
<FormSwitchField
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={theme.spacing(SPACING.MD)}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
name="isPublished"
|
name="isPublished"
|
||||||
label={t("pages.statusPages.form.access.option.published.name")}
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<SwitchComponent
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
<Typography>
|
||||||
/>
|
{t("pages.statusPages.form.access.option.published.name")}
|
||||||
)}
|
</Typography>
|
||||||
{showStep(0) && (
|
</Stack>
|
||||||
<ConfigBox
|
}
|
||||||
title={t("pages.statusPages.form.basicInfo.title")}
|
/>
|
||||||
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
)}
|
||||||
rightContent={
|
{showStep(0) && (
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
<ConfigBox
|
||||||
<FormTextField
|
title={t("pages.statusPages.form.basicInfo.title")}
|
||||||
name="companyName"
|
subtitle={t("pages.statusPages.form.basicInfo.description")}
|
||||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
|
rightContent={
|
||||||
placeholder={t(
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
"pages.statusPages.form.basicInfo.option.name.placeholder"
|
<Controller
|
||||||
)}
|
name="companyName"
|
||||||
/>
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.statusPages.form.basicInfo.option.name.placeholder"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="url"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.statusPages.form.basicInfo.option.url.placeholder"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="customDomain"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.statusPages.form.basicInfo.option.customDomain.label"
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
|
||||||
|
)}
|
||||||
|
helperText={
|
||||||
|
fieldState.error?.message ||
|
||||||
|
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
|
||||||
|
}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showStep(0) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.statusPages.form.monitors.title")}
|
||||||
|
subtitle={t("pages.statusPages.form.monitors.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="monitors"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => {
|
||||||
|
const selectedMonitors = field.value
|
||||||
|
.map((id: string) => monitors.find((m) => m.id === id))
|
||||||
|
.filter((m): m is Monitor => m !== undefined);
|
||||||
|
|
||||||
<FormTextField
|
const handleDragEnd = (result: DropResult) => {
|
||||||
name="url"
|
if (!result.destination) return;
|
||||||
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
|
const reordered = Array.from(field.value);
|
||||||
placeholder={t(
|
const [removed] = reordered.splice(result.source.index, 1);
|
||||||
"pages.statusPages.form.basicInfo.option.url.placeholder"
|
reordered.splice(result.destination.index, 0, removed);
|
||||||
)}
|
field.onChange(reordered);
|
||||||
/>
|
};
|
||||||
<FormTextField
|
|
||||||
name="customDomain"
|
return (
|
||||||
fieldLabel={t(
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
"pages.statusPages.form.basicInfo.option.customDomain.label"
|
<Autocomplete
|
||||||
)}
|
multiple
|
||||||
placeholder={t(
|
options={monitors}
|
||||||
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
|
getOptionLabel={(option: Monitor) => option.name}
|
||||||
)}
|
value={selectedMonitors}
|
||||||
helperText={t(
|
onChange={(_, newValue) => {
|
||||||
"pages.statusPages.form.basicInfo.option.customDomain.helper"
|
field.onChange(newValue.map((m: Monitor) => m.id));
|
||||||
)}
|
}}
|
||||||
/>
|
fieldLabel={t(
|
||||||
</Stack>
|
"pages.statusPages.form.monitors.option.monitors.label"
|
||||||
}
|
)}
|
||||||
/>
|
renderInput={(params) => (
|
||||||
)}
|
<TextField
|
||||||
{showStep(0) && (
|
{...params}
|
||||||
<ConfigBox
|
placeholder={
|
||||||
title={t("pages.statusPages.form.monitors.title")}
|
selectedMonitors.length === 0
|
||||||
subtitle={t("pages.statusPages.form.monitors.description")}
|
? t(
|
||||||
rightContent={
|
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
||||||
<FormMultiSelectField
|
)
|
||||||
sortable
|
: ""
|
||||||
name="monitors"
|
}
|
||||||
fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")}
|
error={!!fieldState.error}
|
||||||
placeholder={t(
|
helperText={fieldState.error?.message}
|
||||||
"pages.statusPages.form.monitors.option.monitors.placeholder"
|
|
||||||
)}
|
|
||||||
options={monitors}
|
|
||||||
renderOptionContent={(monitor) => (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(2)}
|
|
||||||
flexWrap="wrap"
|
|
||||||
>
|
|
||||||
<Typography>
|
|
||||||
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
|
|
||||||
</Typography>
|
|
||||||
{monitor.tags.map((tagId) => {
|
|
||||||
const tag = tags.find(({ id }) => id === tagId);
|
|
||||||
return tag ? (
|
|
||||||
<ColoredLabel
|
|
||||||
key={tag.id}
|
|
||||||
text={tag.name}
|
|
||||||
color={tag.color}
|
|
||||||
/>
|
/>
|
||||||
) : null;
|
)}
|
||||||
})}
|
/>
|
||||||
|
{selectedMonitors.length > 0 && (
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<Droppable droppableId="monitors-list">
|
||||||
|
{(provided) => (
|
||||||
|
<Stack
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
>
|
||||||
|
{selectedMonitors.map((monitor, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={monitor.id}
|
||||||
|
draggableId={monitor.id}
|
||||||
|
index={index}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<Stack
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
|
padding={theme.spacing(LAYOUT.XS)}
|
||||||
|
marginTop={theme.spacing(SPACING.LG)}
|
||||||
|
borderRadius={1}
|
||||||
|
sx={{
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
cursor: "grab",
|
||||||
|
"&:active": { cursor: "grabbing" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={20} />
|
||||||
|
<Typography
|
||||||
|
flexGrow={1}
|
||||||
|
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(
|
||||||
|
field.value.filter(
|
||||||
|
(id: string) => id !== monitor.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
aria-label="Remove monitor"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
);
|
||||||
renderRow={(monitor) => (
|
}}
|
||||||
<Typography flexGrow={1}>
|
/>
|
||||||
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
|
}
|
||||||
</Typography>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
{showStep(0) && (
|
||||||
}
|
<ConfigBox
|
||||||
/>
|
title={t("pages.statusPages.form.timezone.title")}
|
||||||
)}
|
subtitle={t("pages.statusPages.form.timezone.description")}
|
||||||
{showStep(0) && (
|
rightContent={
|
||||||
<ConfigBox
|
<Controller
|
||||||
title={t("pages.statusPages.form.timezone.title")}
|
name="timezone"
|
||||||
subtitle={t("pages.statusPages.form.timezone.description")}
|
control={control}
|
||||||
rightContent={
|
render={({ field }) => (
|
||||||
<FormAutocompleteField
|
<Autocomplete
|
||||||
name="timezone"
|
options={timezones}
|
||||||
options={timezoneOptions}
|
getOptionLabel={(option: TimezoneOption) => option.name}
|
||||||
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
|
value={
|
||||||
placeholder={t(
|
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
|
||||||
"pages.statusPages.form.timezone.option.timezone.placeholder"
|
}
|
||||||
)}
|
onChange={(_, newValue: TimezoneOption | null) => {
|
||||||
/>
|
field.onChange(newValue?._id ?? "");
|
||||||
}
|
}}
|
||||||
/>
|
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
|
||||||
)}
|
renderInput={(params) => (
|
||||||
{showStep(1) && (
|
<TextField
|
||||||
<ConfigBox
|
{...params}
|
||||||
title={t("pages.statusPages.form.appearance.title")}
|
placeholder={t(
|
||||||
subtitle={t("pages.statusPages.form.appearance.description")}
|
"pages.statusPages.form.timezone.option.timezone.placeholder"
|
||||||
rightContent={
|
)}
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
/>
|
||||||
<Stack alignItems={"center"}>
|
|
||||||
<LogoUploadField />
|
|
||||||
</Stack>
|
|
||||||
<FormColorField
|
|
||||||
name="color"
|
|
||||||
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showStep(1) && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.statusPages.form.theme.title")}
|
|
||||||
subtitle={t("pages.statusPages.form.theme.description")}
|
|
||||||
rightContent={<ThemePickerField />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showStep(1) && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.statusPages.form.customCSS.title")}
|
|
||||||
subtitle={t("pages.statusPages.form.customCSS.description")}
|
|
||||||
rightContent={
|
|
||||||
<FormTextField
|
|
||||||
name="customCSS"
|
|
||||||
multiline
|
|
||||||
rows={8}
|
|
||||||
fieldLabel={t("pages.statusPages.form.customCSS.option.customCSS.label")}
|
|
||||||
placeholder={t(
|
|
||||||
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showStep(1) && (
|
|
||||||
<ConfigBox
|
|
||||||
title={t("pages.statusPages.form.features.title")}
|
|
||||||
subtitle={t("pages.statusPages.form.features.description")}
|
|
||||||
rightContent={
|
|
||||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<FormCheckboxField
|
|
||||||
name="showCharts"
|
|
||||||
label={t("pages.statusPages.form.features.option.showCharts.label")}
|
|
||||||
/>
|
|
||||||
<FormCheckboxField
|
|
||||||
name="showInfrastructure"
|
|
||||||
label={t(
|
|
||||||
"pages.statusPages.form.features.option.showInfrastructure.label"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/*
|
)}
|
||||||
<FormCheckboxField
|
/>
|
||||||
name="showUptimePercentage"
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showStep(1) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.statusPages.form.appearance.title")}
|
||||||
|
subtitle={t("pages.statusPages.form.appearance.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Stack alignItems={"center"}>
|
||||||
|
<Controller
|
||||||
|
name="logo"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ImageUpload
|
||||||
|
src={field.value?.data}
|
||||||
|
onChange={(file) => {
|
||||||
|
if (file) {
|
||||||
|
field.onChange({
|
||||||
|
data: file.src,
|
||||||
|
contentType: file.file.type,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
field.onChange(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorInput
|
||||||
|
format="hex"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showStep(1) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.statusPages.form.theme.title")}
|
||||||
|
subtitle={t("pages.statusPages.form.theme.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="theme"
|
||||||
|
control={control}
|
||||||
|
render={({ field: themeField }) => (
|
||||||
|
<Controller
|
||||||
|
name="themeMode"
|
||||||
|
control={control}
|
||||||
|
render={({ field: modeField }) => (
|
||||||
|
<ThemePicker
|
||||||
|
theme={themeField.value ?? "refined"}
|
||||||
|
themeMode={modeField.value ?? "auto"}
|
||||||
|
onThemeChange={themeField.onChange}
|
||||||
|
onThemeModeChange={modeField.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showStep(1) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.statusPages.form.customCSS.title")}
|
||||||
|
subtitle={t("pages.statusPages.form.customCSS.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="customCSS"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
multiline
|
||||||
|
rows={8}
|
||||||
|
fieldLabel={t(
|
||||||
|
"pages.statusPages.form.customCSS.option.customCSS.label"
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showStep(1) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.statusPages.form.features.title")}
|
||||||
|
subtitle={t("pages.statusPages.form.features.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Controller
|
||||||
|
name="showCharts"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("pages.statusPages.form.features.option.showCharts.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="showInfrastructure"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t(
|
||||||
|
"pages.statusPages.form.features.option.showInfrastructure.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <Controller
|
||||||
|
name="showUptimePercentage"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label={t(
|
label={t(
|
||||||
"pages.statusPages.form.features.option.showUptimePercentage.label"
|
"pages.statusPages.form.features.option.showUptimePercentage.label"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<FormCheckboxField
|
/>
|
||||||
name="showAdminLoginLink"
|
<Controller
|
||||||
|
name="showAdminLoginLink"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label={t(
|
label={t(
|
||||||
"pages.statusPages.form.features.option.showAdminLoginLink.label"
|
"pages.statusPages.form.features.option.showAdminLoginLink.label"
|
||||||
)}
|
)}
|
||||||
/> */}
|
/>
|
||||||
</Stack>
|
)}
|
||||||
}
|
/> */}
|
||||||
/>
|
</Stack>
|
||||||
)}
|
}
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent={isCreate ? "space-between" : "flex-end"}
|
|
||||||
>
|
|
||||||
{isCreate && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
disabled={currentStep === 0}
|
|
||||||
onClick={handleBack}
|
|
||||||
>
|
|
||||||
{t("common.buttons.back")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isCreate && currentStep < totalSteps - 1 ? (
|
|
||||||
<Button
|
|
||||||
key="wizard-next"
|
|
||||||
type="button"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleNext}
|
|
||||||
>
|
|
||||||
{t("common.buttons.next")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
key="wizard-save"
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Dialog
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
title={t("common.dialogs.delete.title")}
|
|
||||||
content={t("common.dialogs.delete.description")}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
onCancel={handleDeleteCancel}
|
|
||||||
loading={isDeleting}
|
|
||||||
/>
|
/>
|
||||||
</BasePage>
|
)}
|
||||||
</FormProvider>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent={isCreate ? "space-between" : "flex-end"}
|
||||||
|
>
|
||||||
|
{isCreate && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
disabled={currentStep === 0}
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
{t("common.buttons.back")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isCreate && currentStep < totalSteps - 1 ? (
|
||||||
|
<Button
|
||||||
|
key="wizard-next"
|
||||||
|
type="button"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleNext}
|
||||||
|
>
|
||||||
|
{t("common.buttons.next")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key="wizard-save"
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{t("common.buttons.save")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Dialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
title={t("common.dialogs.delete.title")}
|
||||||
|
content={t("common.dialogs.delete.description")}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={handleDeleteCancel}
|
||||||
|
loading={isDeleting}
|
||||||
|
/>
|
||||||
|
</BasePage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BasePage, BaseFallback } from "@/Components/design-elements";
|
import { BasePage, BaseFallback } from "@/Components/design-elements";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
@@ -9,11 +9,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
import {
|
import { resolveStatusPageTheme } from "@/Types/StatusPage";
|
||||||
isStatusPageRange,
|
|
||||||
resolveStatusPageTheme,
|
|
||||||
type StatusPageRange,
|
|
||||||
} from "@/Types/StatusPage";
|
|
||||||
import {
|
import {
|
||||||
PUBLIC_STATUS_PAGE_PREFIX,
|
PUBLIC_STATUS_PAGE_PREFIX,
|
||||||
type StatusPageResponse,
|
type StatusPageResponse,
|
||||||
@@ -84,37 +80,21 @@ const StatusPageView = () => {
|
|||||||
const { url } = useParams();
|
const { url } = useParams();
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const onRangeChange = (next: StatusPageRange) => {
|
|
||||||
const updated = new URLSearchParams(searchParams);
|
|
||||||
if (next === "latest") {
|
|
||||||
updated.delete("range");
|
|
||||||
} else {
|
|
||||||
updated.set("range", next);
|
|
||||||
}
|
|
||||||
setSearchParams(updated, { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCustomDomainHost = isCustomDomainHost();
|
const onCustomDomainHost = isCustomDomainHost();
|
||||||
const isPublic =
|
const isPublic =
|
||||||
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
||||||
|
|
||||||
const rawRange = searchParams.get("range");
|
|
||||||
const range = isStatusPageRange(rawRange) ? rawRange : "latest";
|
|
||||||
|
|
||||||
const apiUrl = buildStatusPageApiPath({
|
const apiUrl = buildStatusPageApiPath({
|
||||||
url,
|
url,
|
||||||
useCustomDomain: onCustomDomainHost,
|
useCustomDomain: onCustomDomainHost,
|
||||||
range,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, error } = useGet<StatusPageResponse>(
|
const { data, isLoading, error } = useGet<StatusPageResponse>(
|
||||||
apiUrl,
|
apiUrl,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
refreshInterval: 10000,
|
||||||
refreshInterval: range === "latest" ? 10000 : 60000,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,10 +150,6 @@ const StatusPageView = () => {
|
|||||||
statusPage={statusPage}
|
statusPage={statusPage}
|
||||||
monitors={monitors}
|
monitors={monitors}
|
||||||
config={themeConfig}
|
config={themeConfig}
|
||||||
range={range}
|
|
||||||
onRangeChange={onRangeChange}
|
|
||||||
bucketTimezone={data.bucketTimezone ?? "Etc/UTC"}
|
|
||||||
checkTTLDays={data.checkTTLDays}
|
|
||||||
/>
|
/>
|
||||||
</StatusPageThemeProvider>
|
</StatusPageThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
import type { StatusPageThemeTokens } from "../tokens";
|
||||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
|
export type BoldBarKind = "up" | "down" | "empty";
|
||||||
export type BoldHeatCell = HeatCellKind;
|
|
||||||
export type BoldBarKind = BarKind;
|
|
||||||
export type BoldGaugeFill = "ok" | "warm" | "hot";
|
export type BoldGaugeFill = "ok" | "warm" | "hot";
|
||||||
|
|
||||||
export interface BoldStyles {
|
export interface BoldStyles {
|
||||||
@@ -34,9 +32,9 @@ export interface BoldStyles {
|
|||||||
monitorUrl: SxProps<Theme>;
|
monitorUrl: SxProps<Theme>;
|
||||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||||
heatmap: SxProps<Theme>;
|
heatmap: SxProps<Theme>;
|
||||||
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
|
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
|
||||||
histogram: SxProps<Theme>;
|
histogram: SxProps<Theme>;
|
||||||
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
|
||||||
chartStats: SxProps<Theme>;
|
chartStats: SxProps<Theme>;
|
||||||
infra: SxProps<Theme>;
|
infra: SxProps<Theme>;
|
||||||
infraEmpty: SxProps<Theme>;
|
infraEmpty: SxProps<Theme>;
|
||||||
@@ -57,7 +55,6 @@ export const boldStyles = (
|
|||||||
fast: tokens.up,
|
fast: tokens.up,
|
||||||
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
|
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
|
||||||
slow: tokens.warn,
|
slow: tokens.warn,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
@@ -65,14 +62,12 @@ export const boldStyles = (
|
|||||||
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
|
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
|
||||||
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
|
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
|
||||||
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
|
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
|
||||||
degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`,
|
|
||||||
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
|
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
|
||||||
empty: "none",
|
empty: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
const barBg: Record<BoldBarKind, string> = {
|
const barBg: Record<BoldBarKind, string> = {
|
||||||
up: tokens.up,
|
up: tokens.up,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
@@ -327,12 +322,9 @@ export const boldStyles = (
|
|||||||
gap: { xs: "1px", md: "3px" },
|
gap: { xs: "1px", md: "3px" },
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
heatmapCell: (kind, severity = 1) => ({
|
heatmapCell: (kind) => ({
|
||||||
borderRadius: "3px",
|
borderRadius: "3px",
|
||||||
background:
|
background: heatCellBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: heatCellBg[kind],
|
|
||||||
boxShadow: heatCellShadow[kind],
|
boxShadow: heatCellShadow[kind],
|
||||||
opacity: kind === "empty" ? 0.4 : 1,
|
opacity: kind === "empty" ? 0.4 : 1,
|
||||||
transition: "transform 0.15s",
|
transition: "transform 0.15s",
|
||||||
@@ -347,11 +339,8 @@ export const boldStyles = (
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
bar: (kind, heightPct, severity = 1) => ({
|
bar: (kind, heightPct) => ({
|
||||||
background:
|
background: barBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: barBg[kind],
|
|
||||||
borderRadius: "3px",
|
borderRadius: "3px",
|
||||||
minHeight: 4,
|
minHeight: 4,
|
||||||
opacity: kind === "empty" ? 0.4 : 1,
|
opacity: kind === "empty" ? 0.4 : 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
import type { StatusPageThemeTokens } from "../tokens";
|
||||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||||
import {
|
import {
|
||||||
EDITORIAL_SECONDARY_SANS_STACK,
|
EDITORIAL_SECONDARY_SANS_STACK,
|
||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
} from "../shared/fontStacks";
|
} from "../shared/fontStacks";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
|
export type EditorialBarKind = "up" | "down" | "empty";
|
||||||
export type EditorialHeatCell = HeatCellKind;
|
|
||||||
export type EditorialBarKind = BarKind;
|
|
||||||
export type EditorialGaugeFill = "ok" | "warm" | "hot";
|
export type EditorialGaugeFill = "ok" | "warm" | "hot";
|
||||||
|
|
||||||
export interface EditorialStyles {
|
export interface EditorialStyles {
|
||||||
@@ -38,9 +36,9 @@ export interface EditorialStyles {
|
|||||||
monitorUrl: SxProps<Theme>;
|
monitorUrl: SxProps<Theme>;
|
||||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||||
heatmap: SxProps<Theme>;
|
heatmap: SxProps<Theme>;
|
||||||
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
|
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
|
||||||
histogram: SxProps<Theme>;
|
histogram: SxProps<Theme>;
|
||||||
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
|
||||||
chartStats: SxProps<Theme>;
|
chartStats: SxProps<Theme>;
|
||||||
infra: SxProps<Theme>;
|
infra: SxProps<Theme>;
|
||||||
infraEmpty: SxProps<Theme>;
|
infraEmpty: SxProps<Theme>;
|
||||||
@@ -61,14 +59,12 @@ export const editorialStyles = (
|
|||||||
fast: tokens.up,
|
fast: tokens.up,
|
||||||
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
|
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
|
||||||
slow: tokens.warn,
|
slow: tokens.warn,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
|
|
||||||
const barBg: Record<EditorialBarKind, string> = {
|
const barBg: Record<EditorialBarKind, string> = {
|
||||||
up: tokens.up,
|
up: tokens.up,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
@@ -272,11 +268,8 @@ export const editorialStyles = (
|
|||||||
gap: { xs: "1px", md: "2px" },
|
gap: { xs: "1px", md: "2px" },
|
||||||
height: 40,
|
height: 40,
|
||||||
},
|
},
|
||||||
heatmapCell: (kind, severity = 1) => ({
|
heatmapCell: (kind) => ({
|
||||||
background:
|
background: heatCellBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: heatCellBg[kind],
|
|
||||||
opacity: kind === "empty" ? 0.5 : 1,
|
opacity: kind === "empty" ? 0.5 : 1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -288,11 +281,8 @@ export const editorialStyles = (
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 40,
|
height: 40,
|
||||||
},
|
},
|
||||||
bar: (kind, heightPct, severity = 1) => ({
|
bar: (kind, heightPct) => ({
|
||||||
background:
|
background: barBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: barBg[kind],
|
|
||||||
minHeight: 3,
|
minHeight: 3,
|
||||||
opacity: kind === "empty" ? 0.5 : 1,
|
opacity: kind === "empty" ? 0.5 : 1,
|
||||||
height: `${heightPct}%`,
|
height: `${heightPct}%`,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { keyframes } from "@mui/system";
|
import { keyframes } from "@mui/system";
|
||||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
import type { StatusPageThemeTokens } from "../tokens";
|
||||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
|
export type ModernBarKind = "up" | "down" | "empty";
|
||||||
export type ModernHeatCell = HeatCellKind;
|
|
||||||
export type ModernBarKind = BarKind;
|
|
||||||
export type ModernGaugeFill = "ok" | "warm" | "hot";
|
export type ModernGaugeFill = "ok" | "warm" | "hot";
|
||||||
|
|
||||||
export interface ModernStyles {
|
export interface ModernStyles {
|
||||||
@@ -37,9 +35,9 @@ export interface ModernStyles {
|
|||||||
monitorUrl: SxProps<Theme>;
|
monitorUrl: SxProps<Theme>;
|
||||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||||
heatmap: SxProps<Theme>;
|
heatmap: SxProps<Theme>;
|
||||||
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
|
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
|
||||||
histogram: SxProps<Theme>;
|
histogram: SxProps<Theme>;
|
||||||
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
|
||||||
chartStats: SxProps<Theme>;
|
chartStats: SxProps<Theme>;
|
||||||
infra: SxProps<Theme>;
|
infra: SxProps<Theme>;
|
||||||
infraEmpty: SxProps<Theme>;
|
infraEmpty: SxProps<Theme>;
|
||||||
@@ -83,20 +81,17 @@ export const modernStyles = (
|
|||||||
): ModernStyles => {
|
): ModernStyles => {
|
||||||
const cardShadow = isDark ? darkShadow : lightShadow;
|
const cardShadow = isDark ? darkShadow : lightShadow;
|
||||||
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
|
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
|
||||||
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
|
|
||||||
|
|
||||||
const heatCellBg: Record<ModernHeatCell, string> = {
|
const heatCellBg: Record<ModernHeatCell, string> = {
|
||||||
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
|
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||||
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
|
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
|
||||||
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
|
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
|
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||||
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
|
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
|
|
||||||
const barBg: Record<ModernBarKind, string> = {
|
const barBg: Record<ModernBarKind, string> = {
|
||||||
up: tokens.up,
|
up: tokens.up,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
@@ -104,7 +99,7 @@ export const modernStyles = (
|
|||||||
const gaugeFillBg: Record<ModernGaugeFill, string> = {
|
const gaugeFillBg: Record<ModernGaugeFill, string> = {
|
||||||
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
|
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||||
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
|
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
|
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -355,19 +350,13 @@ export const modernStyles = (
|
|||||||
gap: { xs: "1px", md: "3px" },
|
gap: { xs: "1px", md: "3px" },
|
||||||
height: 46,
|
height: 46,
|
||||||
},
|
},
|
||||||
heatmapCell: (kind, severity = 1) => {
|
heatmapCell: (kind) => ({
|
||||||
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
|
borderRadius: "3px",
|
||||||
return {
|
background: heatCellBg[kind],
|
||||||
borderRadius: "3px",
|
opacity: kind === "empty" ? 0.4 : 1,
|
||||||
background:
|
transition: "transform 0.15s",
|
||||||
kind === "degraded"
|
"&:hover": { transform: "scaleY(1.2)" },
|
||||||
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
|
}),
|
||||||
: heatCellBg[kind],
|
|
||||||
opacity: kind === "empty" ? 0.4 : 1,
|
|
||||||
transition: "transform 0.15s",
|
|
||||||
"&:hover": { transform: "scaleY(1.2)" },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
histogram: {
|
histogram: {
|
||||||
padding: { xs: "0 12px", md: "0 24px" },
|
padding: { xs: "0 12px", md: "0 24px" },
|
||||||
@@ -377,11 +366,8 @@ export const modernStyles = (
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 46,
|
height: 46,
|
||||||
},
|
},
|
||||||
bar: (kind, heightPct, severity = 1) => ({
|
bar: (kind, heightPct) => ({
|
||||||
background:
|
background: barBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: barBg[kind],
|
|
||||||
borderRadius: "3px",
|
borderRadius: "3px",
|
||||||
minHeight: 4,
|
minHeight: 4,
|
||||||
opacity: kind === "empty" ? 0.4 : 1,
|
opacity: kind === "empty" ? 0.4 : 1,
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
import type { StatusPageThemeTokens } from "../tokens";
|
||||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||||
|
|
||||||
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
|
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
|
export type RefinedBarKind = "up" | "down" | "empty";
|
||||||
export type RefinedHeatCell = HeatCellKind;
|
|
||||||
export type RefinedBarKind = BarKind;
|
|
||||||
export type RefinedGaugeFill = "ok" | "warm" | "hot";
|
export type RefinedGaugeFill = "ok" | "warm" | "hot";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
@@ -37,9 +35,9 @@ export interface RefinedStyles {
|
|||||||
monitorUrl: SxProps<Theme>;
|
monitorUrl: SxProps<Theme>;
|
||||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||||
heatmap: SxProps<Theme>;
|
heatmap: SxProps<Theme>;
|
||||||
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
|
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
|
||||||
histogram: SxProps<Theme>;
|
histogram: SxProps<Theme>;
|
||||||
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
|
||||||
chartStats: SxProps<Theme>;
|
chartStats: SxProps<Theme>;
|
||||||
infra: SxProps<Theme>;
|
infra: SxProps<Theme>;
|
||||||
infraEmpty: SxProps<Theme>;
|
infraEmpty: SxProps<Theme>;
|
||||||
@@ -73,14 +71,12 @@ export const refinedStyles = (
|
|||||||
fast: tokens.up,
|
fast: tokens.up,
|
||||||
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
|
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
|
||||||
slow: tokens.warn,
|
slow: tokens.warn,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
|
|
||||||
const barBg: Record<RefinedBarKind, string> = {
|
const barBg: Record<RefinedBarKind, string> = {
|
||||||
up: tokens.up,
|
up: tokens.up,
|
||||||
degraded: tokens.degraded,
|
|
||||||
down: tokens.down,
|
down: tokens.down,
|
||||||
empty: tokens.border,
|
empty: tokens.border,
|
||||||
};
|
};
|
||||||
@@ -296,12 +292,9 @@ export const refinedStyles = (
|
|||||||
gap: { xs: "1px", md: "3px" },
|
gap: { xs: "1px", md: "3px" },
|
||||||
height: 42,
|
height: 42,
|
||||||
},
|
},
|
||||||
heatmapCell: (kind, severity = 1) => ({
|
heatmapCell: (kind) => ({
|
||||||
borderRadius: "2px",
|
borderRadius: "2px",
|
||||||
background:
|
background: heatCellBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: heatCellBg[kind],
|
|
||||||
opacity: kind === "empty" ? 0.4 : 1,
|
opacity: kind === "empty" ? 0.4 : 1,
|
||||||
transition: "transform 0.15s",
|
transition: "transform 0.15s",
|
||||||
"&:hover": { transform: "scaleY(1.15)" },
|
"&:hover": { transform: "scaleY(1.15)" },
|
||||||
@@ -315,11 +308,8 @@ export const refinedStyles = (
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 42,
|
height: 42,
|
||||||
},
|
},
|
||||||
bar: (kind, heightPct, severity = 1) => ({
|
bar: (kind, heightPct) => ({
|
||||||
background:
|
background: barBg[kind],
|
||||||
kind === "degraded"
|
|
||||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
|
||||||
: barBg[kind],
|
|
||||||
borderRadius: "2px",
|
borderRadius: "2px",
|
||||||
minHeight: 3,
|
minHeight: 3,
|
||||||
opacity: kind === "empty" ? 0.4 : 1,
|
opacity: kind === "empty" ? 0.4 : 1,
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TriangleAlert } from "lucide-react";
|
|
||||||
|
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { StatusPage, StatusPageRange } from "@/Types/StatusPage";
|
import type { StatusPage } from "@/Types/StatusPage";
|
||||||
import {
|
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||||
getMonitorTypeLabel,
|
|
||||||
STATUS_PAGE_RANGE_DAYS,
|
|
||||||
STATUS_PAGE_RANGES,
|
|
||||||
} from "@/Types/StatusPage";
|
|
||||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||||
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
import {
|
||||||
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
ThemedHeatmap,
|
||||||
|
type HeatCellKind,
|
||||||
|
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||||
|
import {
|
||||||
|
ThemedHistogram,
|
||||||
|
type BarKind,
|
||||||
|
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||||
import {
|
import {
|
||||||
ThemedInfrastructure,
|
ThemedInfrastructure,
|
||||||
type GaugeFillLevel,
|
type GaugeFillLevel,
|
||||||
@@ -25,22 +26,10 @@ import {
|
|||||||
resolveOverallStatus,
|
resolveOverallStatus,
|
||||||
statusBadgeKey,
|
statusBadgeKey,
|
||||||
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
|
||||||
import {
|
|
||||||
checksToCells,
|
|
||||||
dailyBucketsToCells,
|
|
||||||
type BarKind,
|
|
||||||
type HeatCellKind,
|
|
||||||
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { Icon } from "@/Components/design-elements";
|
|
||||||
|
|
||||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||||
import { useMemo, useState } from "react";
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useTheme } from "@mui/material";
|
|
||||||
|
|
||||||
type StatusPageMonitor = Monitor;
|
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||||
|
|
||||||
export interface BaseStyles {
|
export interface BaseStyles {
|
||||||
page: SxProps<Theme>;
|
page: SxProps<Theme>;
|
||||||
@@ -59,9 +48,9 @@ export interface BaseStyles {
|
|||||||
monitorUrl: SxProps<Theme>;
|
monitorUrl: SxProps<Theme>;
|
||||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||||
heatmap: SxProps<Theme>;
|
heatmap: SxProps<Theme>;
|
||||||
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
|
||||||
histogram: SxProps<Theme>;
|
histogram: SxProps<Theme>;
|
||||||
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||||
chartStats: SxProps<Theme>;
|
chartStats: SxProps<Theme>;
|
||||||
infra: SxProps<Theme>;
|
infra: SxProps<Theme>;
|
||||||
infraEmpty: SxProps<Theme>;
|
infraEmpty: SxProps<Theme>;
|
||||||
@@ -93,22 +82,9 @@ interface Props {
|
|||||||
statusPage: StatusPage;
|
statusPage: StatusPage;
|
||||||
monitors: StatusPageMonitor[];
|
monitors: StatusPageMonitor[];
|
||||||
config: ThemeConfig<any>;
|
config: ThemeConfig<any>;
|
||||||
range: StatusPageRange;
|
|
||||||
onRangeChange: (range: StatusPageRange) => void;
|
|
||||||
bucketTimezone: string;
|
|
||||||
checkTTLDays?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BaseStatusPage = ({
|
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||||
statusPage,
|
|
||||||
monitors,
|
|
||||||
config,
|
|
||||||
range,
|
|
||||||
onRangeChange,
|
|
||||||
bucketTimezone,
|
|
||||||
checkTTLDays,
|
|
||||||
}: Props) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tokens, mode } = useStatusPageTheme();
|
const { tokens, mode } = useStatusPageTheme();
|
||||||
const styles = useMemo(
|
const styles = useMemo(
|
||||||
@@ -150,75 +126,33 @@ export const BaseStatusPage = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{statusPage.showCharts && (
|
{statusPage.showCharts && (
|
||||||
<Stack>
|
<Box sx={styles.chartSwitchWrap}>
|
||||||
<Box sx={styles.chartSwitchWrap}>
|
<Box
|
||||||
|
sx={styles.chartSwitch}
|
||||||
|
role="radiogroup"
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={styles.chartSwitch}
|
component="button"
|
||||||
role="radiogroup"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={chartMode === "heatmap"}
|
||||||
|
onClick={() => setChartMode("heatmap")}
|
||||||
|
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||||
>
|
>
|
||||||
<Box
|
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||||
component="button"
|
</Box>
|
||||||
type="button"
|
<Box
|
||||||
role="radio"
|
component="button"
|
||||||
aria-checked={chartMode === "heatmap"}
|
type="button"
|
||||||
onClick={() => setChartMode("heatmap")}
|
role="radio"
|
||||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
aria-checked={chartMode === "histogram"}
|
||||||
>
|
onClick={() => setChartMode("histogram")}
|
||||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||||
</Box>
|
>
|
||||||
<Box
|
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={chartMode === "histogram"}
|
|
||||||
onClick={() => setChartMode("histogram")}
|
|
||||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
|
||||||
>
|
|
||||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={styles.chartSwitchWrap}>
|
</Box>
|
||||||
<Box
|
|
||||||
sx={styles.chartSwitch}
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={t("pages.statusPages.rangeSelectorAria")}
|
|
||||||
>
|
|
||||||
{STATUS_PAGE_RANGES.map((value) => (
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
key={value}
|
|
||||||
aria-checked={range === value}
|
|
||||||
onClick={() => onRangeChange(value)}
|
|
||||||
sx={styles.chartSwitchButton(range === value)}
|
|
||||||
>
|
|
||||||
{t(`pages.statusPages.range.${value}`)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{range !== "latest" &&
|
|
||||||
checkTTLDays &&
|
|
||||||
STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && (
|
|
||||||
<Stack
|
|
||||||
mb={LAYOUT.XS}
|
|
||||||
alignSelf="flex-end"
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={LAYOUT.XXS}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={TriangleAlert}
|
|
||||||
color={theme.palette.warning.main}
|
|
||||||
/>
|
|
||||||
<Typography>
|
|
||||||
{t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
@@ -232,16 +166,6 @@ export const BaseStatusPage = ({
|
|||||||
const badgeTone = monitorBadgeTone(monitor.status);
|
const badgeTone = monitorBadgeTone(monitor.status);
|
||||||
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
|
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
|
||||||
|
|
||||||
const cells =
|
|
||||||
range === "latest"
|
|
||||||
? checksToCells(monitor.recentChecks ?? [])
|
|
||||||
: dailyBucketsToCells(
|
|
||||||
monitor.dailyChecks ?? [],
|
|
||||||
STATUS_PAGE_RANGE_DAYS[range],
|
|
||||||
bucketTimezone,
|
|
||||||
t
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="li"
|
component="li"
|
||||||
@@ -302,13 +226,13 @@ export const BaseStatusPage = ({
|
|||||||
{showChart &&
|
{showChart &&
|
||||||
(chartMode === "heatmap" ? (
|
(chartMode === "heatmap" ? (
|
||||||
<ThemedHeatmap
|
<ThemedHeatmap
|
||||||
cells={cells}
|
checks={monitor.recentChecks ?? []}
|
||||||
containerSx={styles.heatmap}
|
containerSx={styles.heatmap}
|
||||||
cellSx={styles.heatmapCell}
|
cellSx={styles.heatmapCell}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThemedHistogram
|
<ThemedHistogram
|
||||||
cells={cells}
|
checks={monitor.recentChecks ?? []}
|
||||||
containerSx={styles.histogram}
|
containerSx={styles.histogram}
|
||||||
barSx={styles.bar}
|
barSx={styles.bar}
|
||||||
statsSx={styles.chartStats}
|
statsSx={styles.chartStats}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import {
|
|
||||||
ThemedChartTooltip,
|
|
||||||
ThemedDailyTooltip,
|
|
||||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
|
|
||||||
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
|
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
|
||||||
import {
|
|
||||||
computeBarHeights,
|
|
||||||
computeDayBarHeights,
|
|
||||||
MIN_HEIGHT_PCT,
|
|
||||||
} from "@/Utils/DataUtils";
|
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
|
||||||
import { enumerateDays, formatMs } from "@/Utils/TimeUtils";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
|
|
||||||
export type BarKind = "up" | "degraded" | "down" | "empty";
|
|
||||||
export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empty";
|
|
||||||
|
|
||||||
export interface ChartCell {
|
|
||||||
key: string;
|
|
||||||
severity: number;
|
|
||||||
barKind: BarKind;
|
|
||||||
heatKind: HeatCellKind;
|
|
||||||
heightPct: number; // histogram bar height
|
|
||||||
responseTime: number; // 0 for empty cells
|
|
||||||
tooltip: React.ReactNode | null;
|
|
||||||
ariaLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAY_DOWN_THRESHOLD = 0.5;
|
|
||||||
|
|
||||||
// Color interpolation
|
|
||||||
// 0 = perfect
|
|
||||||
// 1 = at or below the down threshold
|
|
||||||
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
|
|
||||||
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
|
|
||||||
|
|
||||||
export const severityFromUpRatio = (upRatio: number): number => {
|
|
||||||
const failed = 1 - upRatio;
|
|
||||||
if (failed <= 0) return 0;
|
|
||||||
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
|
|
||||||
const clamped = Math.min(1, Math.max(0, scaled));
|
|
||||||
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
|
|
||||||
};
|
|
||||||
|
|
||||||
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
|
|
||||||
if (bucket.totalChecks === 0) return "empty";
|
|
||||||
if (upRatio >= 1) return "up";
|
|
||||||
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
|
|
||||||
return "degraded";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const classifyResponseTime = (
|
|
||||||
status: boolean,
|
|
||||||
responseTime: number | undefined
|
|
||||||
): Exclude<HeatCellKind, "empty"> => {
|
|
||||||
if (!status) return "down";
|
|
||||||
const rt = responseTime ?? 0;
|
|
||||||
if (rt > 500) return "slow";
|
|
||||||
if (rt > 250) return "med";
|
|
||||||
return "fast";
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyCell = (key: string): ChartCell => ({
|
|
||||||
key,
|
|
||||||
barKind: "empty",
|
|
||||||
heatKind: "empty",
|
|
||||||
heightPct: MIN_HEIGHT_PCT,
|
|
||||||
responseTime: 0,
|
|
||||||
tooltip: null,
|
|
||||||
ariaLabel: "",
|
|
||||||
severity: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
|
||||||
...cells,
|
|
||||||
...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) =>
|
|
||||||
emptyCell(`empty-${cells.length + i}`)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
|
||||||
const source = checks.slice(-MAX_RECENT_CHECKS);
|
|
||||||
const heights = computeBarHeights(source);
|
|
||||||
const cells = source.map(
|
|
||||||
(check, i): ChartCell => ({
|
|
||||||
key: check.id ?? String(i),
|
|
||||||
barKind: check.status ? "up" : "down",
|
|
||||||
heatKind: classifyResponseTime(check.status, check.responseTime),
|
|
||||||
heightPct: heights[i] ?? MIN_HEIGHT_PCT,
|
|
||||||
responseTime: check.responseTime ?? 0,
|
|
||||||
tooltip: <ThemedChartTooltip check={check} />,
|
|
||||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
|
||||||
severity: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return padCells(cells, MAX_RECENT_CHECKS);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dailyBucketsToCells = (
|
|
||||||
buckets: DailyCheckBucket[],
|
|
||||||
days: number,
|
|
||||||
bucketTimezone: string,
|
|
||||||
t: TFunction
|
|
||||||
): ChartCell[] => {
|
|
||||||
const byDate = new Map(buckets.map((b) => [b.date, b]));
|
|
||||||
const heights = computeDayBarHeights(buckets);
|
|
||||||
const enumerated = enumerateDays(days, bucketTimezone);
|
|
||||||
return enumerated.map((date) => {
|
|
||||||
const bucket = byDate.get(date);
|
|
||||||
if (!bucket) return emptyCell(`day-${date}`);
|
|
||||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
|
||||||
|
|
||||||
const barKind = classifyDay(bucket, upRatio);
|
|
||||||
return {
|
|
||||||
key: `day-${date}`,
|
|
||||||
severity:
|
|
||||||
barKind === "degraded"
|
|
||||||
? severityFromUpRatio(upRatio)
|
|
||||||
: barKind === "down"
|
|
||||||
? 1
|
|
||||||
: 0,
|
|
||||||
barKind,
|
|
||||||
heatKind:
|
|
||||||
barKind === "up"
|
|
||||||
? classifyResponseTime(true, bucket.avgResponseTime ?? 0)
|
|
||||||
: barKind,
|
|
||||||
heightPct: heights.get(date) ?? MIN_HEIGHT_PCT,
|
|
||||||
responseTime: bucket.avgResponseTime ?? 0,
|
|
||||||
tooltip: <ThemedDailyTooltip bucket={bucket} />,
|
|
||||||
ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", {
|
|
||||||
date,
|
|
||||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
|
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
|
||||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { timezone } = useStatusPageTheme();
|
|
||||||
return (
|
|
||||||
<Stack gap={0.25}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{check.status
|
|
||||||
? formatMs(check.responseTime)
|
|
||||||
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Stack gap={0.25}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
fontWeight={600}
|
|
||||||
>
|
|
||||||
{bucket.date}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{t("pages.statusPages.monitorsList.chart.dayUptime", {
|
|
||||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
|
||||||
upChecks: bucket.upChecks.toLocaleString(),
|
|
||||||
totalChecks: bucket.totalChecks.toLocaleString(),
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
{bucket.avgResponseTime !== null && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ opacity: 0.8 }}
|
|
||||||
>
|
|
||||||
{t("pages.statusPages.monitorsList.chart.dayAvgResponse", {
|
|
||||||
value: formatMs(bucket.avgResponseTime),
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,49 +1,86 @@
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
ChartCell,
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
HeatCellKind,
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
|
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||||
|
|
||||||
|
const CELLS = MAX_RECENT_CHECKS;
|
||||||
|
|
||||||
|
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cells: ChartCell[];
|
checks: CheckSnapshot[];
|
||||||
containerSx: SxProps<Theme>;
|
containerSx: SxProps<Theme>;
|
||||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
|
||||||
|
if (!check.status) return "down";
|
||||||
|
const rt = check.responseTime ?? 0;
|
||||||
|
if (rt > 500) return "slow";
|
||||||
|
if (rt > 250) return "med";
|
||||||
|
return "fast";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { timezone } = useStatusPageTheme();
|
||||||
|
|
||||||
|
const source = checks.slice(-CELLS);
|
||||||
|
const padded: (CheckSnapshot | null)[] = [
|
||||||
|
...source,
|
||||||
|
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={containerSx}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||||
>
|
>
|
||||||
{cells.map((cell) => {
|
{padded.map((check, i) => {
|
||||||
if (cell.heatKind === "empty") {
|
if (!check) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={`empty-${i}`}
|
||||||
sx={cellSx("empty")}
|
sx={cellSx("empty")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const kind = classify(check);
|
||||||
|
const tooltipContent = (
|
||||||
|
<Stack gap={0.25}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{check.status
|
||||||
|
? `${check.responseTime} ms`
|
||||||
|
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={cell.key}
|
key={check.id ?? i}
|
||||||
title={cell.tooltip}
|
title={tooltipContent}
|
||||||
arrow
|
arrow
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={cellSx(cell.heatKind, cell.severity)}
|
sx={cellSx(kind)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,67 +2,101 @@ import { useMemo } from "react";
|
|||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import type {
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
BarKind,
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
ChartCell,
|
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||||
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
|
const CELLS = MAX_RECENT_CHECKS;
|
||||||
|
const MIN_HEIGHT_PCT = 6;
|
||||||
|
|
||||||
|
export type BarKind = "up" | "down" | "empty";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cells: ChartCell[];
|
checks: CheckSnapshot[];
|
||||||
containerSx: SxProps<Theme>;
|
containerSx: SxProps<Theme>;
|
||||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||||
statsSx: SxProps<Theme>;
|
statsSx: SxProps<Theme>;
|
||||||
statsGap?: number;
|
statsGap?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
|
||||||
|
check.status ? "up" : "down";
|
||||||
|
|
||||||
export const ThemedHistogram = ({
|
export const ThemedHistogram = ({
|
||||||
cells,
|
checks,
|
||||||
containerSx,
|
containerSx,
|
||||||
barSx,
|
barSx,
|
||||||
statsSx,
|
statsSx,
|
||||||
statsGap = 1,
|
statsGap = 1,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { timezone } = useStatusPageTheme();
|
||||||
|
|
||||||
const { avg, peak } = useMemo(() => {
|
const { padded, max, avg, peak } = useMemo(() => {
|
||||||
const valid = cells.filter((cell) => cell.responseTime > 0);
|
const source = checks.slice(-CELLS);
|
||||||
const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0;
|
const out: (CheckSnapshot | null)[] = [
|
||||||
|
...source,
|
||||||
|
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
|
||||||
|
];
|
||||||
|
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
|
||||||
|
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
|
||||||
const avgRt = valid.length
|
const avgRt = valid.length
|
||||||
? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length
|
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
|
||||||
: 0;
|
: 0;
|
||||||
return { avg: formatMs(avgRt), peak: formatMs(maxRt) };
|
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
|
||||||
}, [cells]);
|
}, [checks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={statsGap}>
|
<Stack gap={statsGap}>
|
||||||
<Box
|
<Box sx={containerSx}>
|
||||||
sx={[
|
{padded.map((check, i) => {
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
if (!check) {
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{cells.map((cell) => {
|
|
||||||
if (cell.barKind === "empty") {
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={`empty-${i}`}
|
||||||
sx={barSx("empty", cell.heightPct)}
|
sx={barSx("empty", MIN_HEIGHT_PCT)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const height = Math.max(
|
||||||
|
MIN_HEIGHT_PCT,
|
||||||
|
Math.round((check.responseTime / max) * 100)
|
||||||
|
);
|
||||||
|
const tooltipContent = (
|
||||||
|
<Stack gap={0.25}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{check.status
|
||||||
|
? `${check.responseTime} ms`
|
||||||
|
: t("pages.statusPages.monitorsList.chart.downTooltip")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(
|
||||||
|
check.createdAt,
|
||||||
|
"ddd, MMMM D, YYYY, HH:mm A",
|
||||||
|
timezone
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={cell.key}
|
key={check.id ?? i}
|
||||||
title={cell.tooltip}
|
title={tooltipContent}
|
||||||
arrow
|
arrow
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
sx={barSx(tone(check), height)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={`${check.responseTime} ms`}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface InfraSx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
monitor: Monitor;
|
monitor: Monitor & { checks?: Monitor["recentChecks"] };
|
||||||
sxApi: InfraSx;
|
sxApi: InfraSx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +36,7 @@ const heatLevel = (value: number): GaugeFillLevel =>
|
|||||||
|
|
||||||
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
|
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const recentChecks = monitor.recentChecks ?? [];
|
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
|
||||||
const latest = recentChecks[recentChecks.length - 1];
|
|
||||||
|
|
||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
|
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
|
||||||
|
|||||||
@@ -243,10 +243,3 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
|
|||||||
editorial,
|
editorial,
|
||||||
minimal,
|
minimal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Interpolate between a theme's up and degraded colors by severity (0..1).
|
|
||||||
export const mixSeverityColor = (
|
|
||||||
up: string,
|
|
||||||
degraded: string,
|
|
||||||
severity: number
|
|
||||||
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||||
import { Button } from "@/Components/inputs";
|
import { TextField, Button, ColorInput } from "@/Components/inputs";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useForm, FormProvider } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { mutate } from "swr";
|
import { mutate } from "swr";
|
||||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||||
@@ -14,8 +14,6 @@ import type { TagFormData } from "@/Validation/tag";
|
|||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTagsForm } from "@/Hooks/useTagsForm";
|
import { useTagsForm } from "@/Hooks/useTagsForm";
|
||||||
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|
||||||
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
|
|
||||||
|
|
||||||
const TagsCreatePage = () => {
|
const TagsCreatePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -36,7 +34,7 @@ const TagsCreatePage = () => {
|
|||||||
defaultValues: defaults,
|
defaultValues: defaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { reset, handleSubmit } = form;
|
const { control, reset, handleSubmit } = form;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset(defaults);
|
reset(defaults);
|
||||||
@@ -53,49 +51,67 @@ const TagsCreatePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<BasePage
|
||||||
<BasePage
|
component="form"
|
||||||
component="form"
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
>
|
||||||
>
|
<ConfigBox
|
||||||
<ConfigBox
|
title={t("pages.tags.form.name.title")}
|
||||||
title={t("pages.tags.form.name.title")}
|
subtitle={t("pages.tags.form.name.description")}
|
||||||
subtitle={t("pages.tags.form.name.description")}
|
rightContent={
|
||||||
rightContent={
|
<Controller
|
||||||
<FormTextField
|
name="name"
|
||||||
name="name"
|
control={control}
|
||||||
fieldLabel={t("pages.tags.form.name.optionName")}
|
defaultValue={defaults.name}
|
||||||
placeholder={t("pages.tags.form.name.placeholder")}
|
render={({ field, fieldState }) => (
|
||||||
/>
|
<TextField
|
||||||
}
|
{...field}
|
||||||
/>
|
type="text"
|
||||||
<ConfigBox
|
fieldLabel={t("pages.tags.form.name.optionName")}
|
||||||
title={t("pages.tags.form.color.title")}
|
placeholder={t("pages.tags.form.name.placeholder")}
|
||||||
subtitle={t("pages.tags.form.color.description")}
|
fullWidth
|
||||||
rightContent={
|
error={!!fieldState.error}
|
||||||
<FormColorField
|
helperText={fieldState.error?.message ?? ""}
|
||||||
name="color"
|
/>
|
||||||
fieldLabel={t("pages.tags.form.color.optionName")}
|
)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.tags.form.color.title")}
|
||||||
|
subtitle={t("pages.tags.form.color.description")}
|
||||||
|
rightContent={
|
||||||
|
<Controller
|
||||||
|
name="color"
|
||||||
|
control={control}
|
||||||
|
defaultValue={defaults.color}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ColorInput
|
||||||
|
format="hex"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
fieldLabel={t("pages.tags.form.color.optionName")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
spacing={theme.spacing(2)}
|
spacing={theme.spacing(2)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={isSubmitting || isPatching}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
>
|
>
|
||||||
<Button
|
{t("common.buttons.save")}
|
||||||
loading={isSubmitting || isPatching}
|
</Button>
|
||||||
type="submit"
|
</Stack>
|
||||||
variant="contained"
|
</BasePage>
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{t("common.buttons.save")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</BasePage>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import Box from "@mui/material/Box";
|
|||||||
import type { Header } from "@/Components/design-elements";
|
import type { Header } from "@/Components/design-elements";
|
||||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
import prettyMilliseconds from "pretty-ms";
|
||||||
|
|
||||||
export const GeoChecksTable = ({
|
export const GeoChecksTable = ({
|
||||||
geoChecks,
|
geoChecks,
|
||||||
@@ -68,7 +69,7 @@ export const GeoChecksTable = ({
|
|||||||
content: t("common.table.headers.responseTime"),
|
content: t("common.table.headers.responseTime"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
if (!row.timings?.total) return "N/A";
|
if (!row.timings?.total) return "N/A";
|
||||||
return formatMs(row.timings.total);
|
return prettyMilliseconds(row.timings.total, { compact: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -38,11 +38,6 @@ interface CertificateResponse {
|
|||||||
certificateDate: string;
|
certificateDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DomainResponse {
|
|
||||||
domain: string;
|
|
||||||
expiryDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UptimeDetailsPage = () => {
|
const UptimeDetailsPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
@@ -62,6 +57,7 @@ const UptimeDetailsPage = () => {
|
|||||||
}
|
}
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("dateRange", dateRange);
|
params.append("dateRange", dateRange);
|
||||||
|
params.append("normalize", "true");
|
||||||
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
|
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
|
||||||
}, [monitorId, dateRange]);
|
}, [monitorId, dateRange]);
|
||||||
|
|
||||||
@@ -76,7 +72,6 @@ const UptimeDetailsPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const monitorData = monitorDetailsData?.monitorData;
|
const monitorData = monitorDetailsData?.monitorData;
|
||||||
|
|
||||||
const monitor = monitorData?.monitor;
|
const monitor = monitorData?.monitor;
|
||||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||||
|
|
||||||
@@ -107,29 +102,6 @@ const UptimeDetailsPage = () => {
|
|||||||
);
|
);
|
||||||
}, [certificateData, uiTimezone]);
|
}, [certificateData, uiTimezone]);
|
||||||
|
|
||||||
// Domain expiry fetch - only for HTTP monitors
|
|
||||||
const domainUrl = useMemo(() => {
|
|
||||||
if (!monitorId || monitor?.type !== "http") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `/monitors/domain/${monitorId}`;
|
|
||||||
}, [monitorId, monitor?.type]);
|
|
||||||
|
|
||||||
const { data: domainData } = useGet<DomainResponse>(
|
|
||||||
domainUrl,
|
|
||||||
{},
|
|
||||||
{ revalidateOnFocus: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const domainExpiry = useMemo(() => {
|
|
||||||
if (!domainData?.expiryDate) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
formatDateWithTz(domainData.expiryDate, certificateDateFormat, uiTimezone) ?? "N/A"
|
|
||||||
);
|
|
||||||
}, [domainData, uiTimezone]);
|
|
||||||
|
|
||||||
const checksUrl = useMemo(() => {
|
const checksUrl = useMemo(() => {
|
||||||
if (!monitorId || !monitor?.type) {
|
if (!monitorId || !monitor?.type) {
|
||||||
return null;
|
return null;
|
||||||
@@ -193,18 +165,10 @@ const UptimeDetailsPage = () => {
|
|||||||
{ keepPreviousData: true, revalidateOnFocus: false }
|
{ keepPreviousData: true, revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!monitorData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!monitor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
|
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
|
||||||
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
|
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
|
||||||
|
|
||||||
const geoLocations = monitor.geoCheckLocations;
|
const geoLocations = monitor?.geoCheckLocations;
|
||||||
|
|
||||||
const checks = checksData?.checks ?? [];
|
const checks = checksData?.checks ?? [];
|
||||||
const checksCount = checksData?.checksCount ?? 0;
|
const checksCount = checksData?.checksCount ?? 0;
|
||||||
@@ -221,7 +185,6 @@ const UptimeDetailsPage = () => {
|
|||||||
monitor={monitor}
|
monitor={monitor}
|
||||||
monitorStats={monitorStats}
|
monitorStats={monitorStats}
|
||||||
certificateExpiry={certificateExpiry}
|
certificateExpiry={certificateExpiry}
|
||||||
domainExpiry={domainExpiry}
|
|
||||||
/>
|
/>
|
||||||
<HeaderTimeRange
|
<HeaderTimeRange
|
||||||
isLoading={monitorIsLoading || checksIsLoading}
|
isLoading={monitorIsLoading || checksIsLoading}
|
||||||
@@ -252,7 +215,7 @@ const UptimeDetailsPage = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<HistogramDetails
|
<HistogramDetails
|
||||||
checks={monitorData.groupedChecks}
|
checks={monitorData?.groupedChecks || []}
|
||||||
range={dateRange}
|
range={dateRange}
|
||||||
/>
|
/>
|
||||||
<ChecksTable
|
<ChecksTable
|
||||||
|
|||||||
@@ -39,14 +39,11 @@ import CreateNotifications from "@/Pages/Notifications/create";
|
|||||||
import Tags from "@/Pages/Tags";
|
import Tags from "@/Pages/Tags";
|
||||||
import CreateTags from "@/Pages/Tags/create";
|
import CreateTags from "@/Pages/Tags/create";
|
||||||
|
|
||||||
// Proxies
|
|
||||||
import Proxies from "@/Pages/Proxies";
|
|
||||||
import CreateProxies from "@/Pages/Proxies/create";
|
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
import Account from "@/Pages/Account";
|
import Account from "@/Pages/Account";
|
||||||
import EditUser from "@/Pages/Account/EditUser";
|
import EditUser from "@/Pages/Account/EditUser";
|
||||||
import Settings from "@/Pages/Settings";
|
import Settings from "@/Pages/Settings";
|
||||||
|
|
||||||
import Maintenance from "@/Pages/Maintenance";
|
import Maintenance from "@/Pages/Maintenance";
|
||||||
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
|
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
|
||||||
|
|
||||||
@@ -55,8 +52,10 @@ import Logs from "@/Pages/Logs";
|
|||||||
|
|
||||||
// Routing
|
// Routing
|
||||||
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
|
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
|
||||||
|
|
||||||
import CreateMonitor from "@/Pages/CreateMonitor";
|
import CreateMonitor from "@/Pages/CreateMonitor";
|
||||||
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
|
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
|
||||||
|
|
||||||
const Routes = () => {
|
const Routes = () => {
|
||||||
if (isCustomDomainHost()) {
|
if (isCustomDomainHost()) {
|
||||||
return (
|
return (
|
||||||
@@ -189,24 +188,12 @@ const Routes = () => {
|
|||||||
path="tags/create"
|
path="tags/create"
|
||||||
element={<CreateTags />}
|
element={<CreateTags />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="tags/configure/:tagId"
|
path="tags/configure/:tagId"
|
||||||
element={<CreateTags />}
|
element={<CreateTags />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
|
||||||
path="proxies"
|
|
||||||
element={<Proxies />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="proxies/create"
|
|
||||||
element={<CreateProxies />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="proxies/configure/:proxyId"
|
|
||||||
element={<CreateProxies />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="maintenance"
|
path="maintenance"
|
||||||
element={<Maintenance />}
|
element={<Maintenance />}
|
||||||
|
|||||||
+10
-54
@@ -149,15 +149,6 @@ export interface GroupedCheck {
|
|||||||
totalChecks: number;
|
totalChecks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupedUptimeCheck extends GroupedCheck {
|
|
||||||
avgDns: number;
|
|
||||||
avgTcp: number;
|
|
||||||
avgTls: number;
|
|
||||||
avgRequest: number;
|
|
||||||
avgFirstByte: number;
|
|
||||||
avgDownload: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageSpeedGroupedCheck {
|
export interface PageSpeedGroupedCheck {
|
||||||
bucketDate: string;
|
bucketDate: string;
|
||||||
performance: number;
|
performance: number;
|
||||||
@@ -250,53 +241,18 @@ export interface ChecksSummary {
|
|||||||
downChecks: number;
|
downChecks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SnapshotCpuInfo = Pick<
|
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
|
||||||
CheckCpuInfo,
|
originalResponseTime: number;
|
||||||
| "physical_core"
|
|
||||||
| "logical_core"
|
|
||||||
| "frequency"
|
|
||||||
| "current_frequency"
|
|
||||||
| "temperature"
|
|
||||||
| "usage_percent"
|
|
||||||
>;
|
|
||||||
export type SnapshotMemoryInfo = Pick<
|
|
||||||
CheckMemoryInfo,
|
|
||||||
"total_bytes" | "used_bytes" | "usage_percent"
|
|
||||||
>;
|
|
||||||
export type SnapshotDiskInfo = Pick<
|
|
||||||
CheckDiskInfo,
|
|
||||||
"device" | "total_bytes" | "used_bytes" | "usage_percent"
|
|
||||||
>;
|
|
||||||
export type SnapshotHostInfo = Pick<CheckHostInfo, "os" | "platform" | "pretty_name">;
|
|
||||||
|
|
||||||
export type CheckSnapshot = Pick<
|
|
||||||
Check,
|
|
||||||
| "id"
|
|
||||||
| "status"
|
|
||||||
| "responseTime"
|
|
||||||
| "statusCode"
|
|
||||||
| "message"
|
|
||||||
| "createdAt"
|
|
||||||
| "accessibility"
|
|
||||||
| "bestPractices"
|
|
||||||
| "seo"
|
|
||||||
| "performance"
|
|
||||||
| "audits"
|
|
||||||
> & {
|
|
||||||
cpu?: SnapshotCpuInfo;
|
|
||||||
memory?: SnapshotMemoryInfo;
|
|
||||||
disk?: SnapshotDiskInfo[];
|
|
||||||
host?: SnapshotHostInfo;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface HasResponseTime {
|
export interface HasResponseTime {
|
||||||
responseTime: number;
|
responseTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyCheckBucket {
|
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
|
||||||
monitorId: string;
|
originalResponseTime: number;
|
||||||
date: string;
|
};
|
||||||
totalChecks: number;
|
|
||||||
upChecks: number;
|
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
|
||||||
downChecks: number;
|
originalAvgResponseTime: number;
|
||||||
avgResponseTime: number | null; // null when no check that day recorded a response time
|
};
|
||||||
}
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import type {
|
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
|
||||||
GroupedCheck,
|
|
||||||
CheckSnapshot,
|
|
||||||
GroupedUptimeCheck,
|
|
||||||
DailyCheckBucket,
|
|
||||||
} from "@/Types/Check";
|
|
||||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||||
@@ -25,56 +20,6 @@ export const MonitorTypes = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type MonitorType = (typeof MonitorTypes)[number];
|
export type MonitorType = (typeof MonitorTypes)[number];
|
||||||
|
|
||||||
// The subset offered by the CreateMonitor type selector, in display order.
|
|
||||||
// pagespeed/hardware are set by their own pages; unknown is never selectable.
|
|
||||||
export const SelectableMonitorTypes = [
|
|
||||||
"http",
|
|
||||||
"ping",
|
|
||||||
"docker",
|
|
||||||
"port",
|
|
||||||
"game",
|
|
||||||
"grpc",
|
|
||||||
"websocket",
|
|
||||||
"dns",
|
|
||||||
] as const;
|
|
||||||
export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number];
|
|
||||||
|
|
||||||
// Irregular i18n key fragments shared by the type selector's label
|
|
||||||
// (pages.common.monitors.monitorTypes.<fragment>), its description
|
|
||||||
// (pages.createMonitor.form.type.<fragment>Description), and status page
|
|
||||||
// type labels (via getMonitorTypeLabel). Covers every type with a label;
|
|
||||||
// only selectable types have description keys.
|
|
||||||
export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
|
|
||||||
Partial<Record<MonitorType, string>> = {
|
|
||||||
http: "optionHttp",
|
|
||||||
ping: "optionPing",
|
|
||||||
docker: "optionDocker",
|
|
||||||
port: "optionPort",
|
|
||||||
game: "optionGame",
|
|
||||||
grpc: "optionGrpc",
|
|
||||||
websocket: "optionWebSocket",
|
|
||||||
dns: "optionDns",
|
|
||||||
hardware: "optionHardware",
|
|
||||||
pagespeed: "optionPagespeed",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MonitorIntervalOptions = [
|
|
||||||
{ value: 15000, labelKey: "fifteenSeconds" },
|
|
||||||
{ value: 30000, labelKey: "thirtySeconds" },
|
|
||||||
{ value: 60000, labelKey: "oneMinute" },
|
|
||||||
{ value: 120000, labelKey: "twoMinutes" },
|
|
||||||
{ value: 180000, labelKey: "threeMinutes" },
|
|
||||||
{ value: 240000, labelKey: "fourMinutes" },
|
|
||||||
{ value: 300000, labelKey: "fiveMinutes" },
|
|
||||||
{ value: 600000, labelKey: "tenMinutes" },
|
|
||||||
{ value: 900000, labelKey: "fifteenMinutes" },
|
|
||||||
{ value: 1800000, labelKey: "thirtyMinutes" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const GeoCheckIntervalOptions = MonitorIntervalOptions.filter(
|
|
||||||
({ value }) => value >= 300000
|
|
||||||
);
|
|
||||||
|
|
||||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||||
|
|
||||||
@@ -97,9 +42,7 @@ export const MonitorStatuses = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||||
|
|
||||||
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
|
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||||
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
|
|
||||||
export const DefaultMonitorMatchMethod: MonitorMatchMethod = "equal";
|
|
||||||
|
|
||||||
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||||
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||||
@@ -109,10 +52,6 @@ export const HttpMethods = ["GET", "HEAD"] as const;
|
|||||||
export type HttpMethod = (typeof HttpMethods)[number];
|
export type HttpMethod = (typeof HttpMethods)[number];
|
||||||
export const DefaultHttpMethod: HttpMethod = "GET";
|
export const DefaultHttpMethod: HttpMethod = "GET";
|
||||||
|
|
||||||
export const ProxyModes = ["inherit", "none", "custom"] as const;
|
|
||||||
export type ProxyMode = (typeof ProxyModes)[number];
|
|
||||||
export const DefaultProxyMode: ProxyMode = "inherit";
|
|
||||||
|
|
||||||
export interface Monitor {
|
export interface Monitor {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -125,8 +64,6 @@ export interface Monitor {
|
|||||||
statusWindowThreshold: number;
|
statusWindowThreshold: number;
|
||||||
type: MonitorType;
|
type: MonitorType;
|
||||||
ignoreTlsErrors: boolean;
|
ignoreTlsErrors: boolean;
|
||||||
proxyMode: ProxyMode;
|
|
||||||
proxyId?: string;
|
|
||||||
useAdvancedMatching: boolean;
|
useAdvancedMatching: boolean;
|
||||||
jsonPath?: string;
|
jsonPath?: string;
|
||||||
expectedValue?: string;
|
expectedValue?: string;
|
||||||
@@ -160,7 +97,6 @@ export interface Monitor {
|
|||||||
dnsServer?: string;
|
dnsServer?: string;
|
||||||
dnsRecordType?: DnsRecordType;
|
dnsRecordType?: DnsRecordType;
|
||||||
recentChecks: CheckSnapshot[];
|
recentChecks: CheckSnapshot[];
|
||||||
dailyChecks?: DailyCheckBucket[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -201,7 +137,7 @@ export interface MonitorStats {
|
|||||||
|
|
||||||
export interface MonitorData {
|
export interface MonitorData {
|
||||||
monitor: Monitor;
|
monitor: Monitor;
|
||||||
groupedChecks: GroupedUptimeCheck[];
|
groupedChecks: GroupedCheck[];
|
||||||
groupedUpChecks: GroupedCheck[];
|
groupedUpChecks: GroupedCheck[];
|
||||||
groupedDownChecks: GroupedCheck[];
|
groupedDownChecks: GroupedCheck[];
|
||||||
groupedAvgResponseTime: number;
|
groupedAvgResponseTime: number;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export const NotificationChannels = [
|
|||||||
"slack",
|
"slack",
|
||||||
"discord",
|
"discord",
|
||||||
"webhook",
|
"webhook",
|
||||||
"rocket_chat",
|
|
||||||
"pager_duty",
|
"pager_duty",
|
||||||
"matrix",
|
"matrix",
|
||||||
"teams",
|
"teams",
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
export const ProxyProtocols = ["http", "https"] as const;
|
|
||||||
export type ProxyProtocol = (typeof ProxyProtocols)[number];
|
|
||||||
|
|
||||||
export interface Proxy {
|
|
||||||
id: string;
|
|
||||||
teamId: string;
|
|
||||||
name: string;
|
|
||||||
protocol: ProxyProtocol;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
|
|
||||||
@@ -24,8 +24,6 @@ export interface Settings {
|
|||||||
showURL: boolean;
|
showURL: boolean;
|
||||||
singleton: boolean;
|
singleton: boolean;
|
||||||
globalThresholds?: SettingsThresholds;
|
globalThresholds?: SettingsThresholds;
|
||||||
globalProxyEnabled: boolean;
|
|
||||||
globalProxyId?: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -33,11 +31,5 @@ export interface Settings {
|
|||||||
export interface AppSettingsResponse {
|
export interface AppSettingsResponse {
|
||||||
pagespeedKeySet: boolean;
|
pagespeedKeySet: boolean;
|
||||||
emailPasswordSet: boolean;
|
emailPasswordSet: boolean;
|
||||||
globalProxy: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
} | null;
|
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
import { monitorTypeLabelKey } from "@/Types/Monitor";
|
|
||||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||||
export type MonitorDisplayType = "uptime" | "infrastructure";
|
export type MonitorDisplayType = "uptime" | "infrastructure";
|
||||||
|
|
||||||
|
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
|
||||||
|
http: "pages.common.monitors.monitorTypes.optionHttp",
|
||||||
|
ping: "pages.common.monitors.monitorTypes.optionPing",
|
||||||
|
docker: "pages.common.monitors.monitorTypes.optionDocker",
|
||||||
|
port: "pages.common.monitors.monitorTypes.optionPort",
|
||||||
|
game: "pages.common.monitors.monitorTypes.optionGame",
|
||||||
|
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
|
||||||
|
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
|
||||||
|
hardware: "pages.common.monitors.monitorTypes.optionHardware",
|
||||||
|
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
|
||||||
|
};
|
||||||
|
|
||||||
export const getMonitorTypeLabel = (
|
export const getMonitorTypeLabel = (
|
||||||
type: MonitorType,
|
type: MonitorType,
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
): string => {
|
): string => {
|
||||||
const fragment = monitorTypeLabelKey[type];
|
const key = MONITOR_TYPE_KEYS[type];
|
||||||
return fragment ? t(`pages.common.monitors.monitorTypes.${fragment}`) : type;
|
return key ? t(key) : type;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_PAGE_THEMES = [
|
export const STATUS_PAGE_THEMES = [
|
||||||
@@ -24,20 +35,6 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
|
|||||||
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
|
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
|
||||||
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
|
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
|
||||||
|
|
||||||
export const STATUS_PAGE_DAY_RANGES = ["30d", "60d", "90d"] as const;
|
|
||||||
export type StatusPageDayRange = (typeof STATUS_PAGE_DAY_RANGES)[number];
|
|
||||||
export const STATUS_PAGE_RANGES = ["latest", ...STATUS_PAGE_DAY_RANGES] as const;
|
|
||||||
export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number];
|
|
||||||
|
|
||||||
export const STATUS_PAGE_RANGE_DAYS: Record<StatusPageDayRange, number> = {
|
|
||||||
"30d": 30,
|
|
||||||
"60d": 60,
|
|
||||||
"90d": 90,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isStatusPageRange = (value: string | null): value is StatusPageRange =>
|
|
||||||
STATUS_PAGE_RANGES.some((range) => range === value);
|
|
||||||
|
|
||||||
export const resolveStatusPageTheme = (
|
export const resolveStatusPageTheme = (
|
||||||
value: string | null | undefined
|
value: string | null | undefined
|
||||||
): StatusPageTheme =>
|
): StatusPageTheme =>
|
||||||
@@ -81,7 +78,4 @@ export interface StatusPage {
|
|||||||
export interface StatusPageResponse {
|
export interface StatusPageResponse {
|
||||||
statusPage: StatusPage;
|
statusPage: StatusPage;
|
||||||
monitors: Monitor[];
|
monitors: Monitor[];
|
||||||
range?: StatusPageDayRange; // present only when range !== "latest"
|
|
||||||
bucketTimezone?: string;
|
|
||||||
checkTTLDays?: number;
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,47 @@
|
|||||||
|
const MIN_OUT = 10;
|
||||||
|
const MAX_OUT = 100;
|
||||||
|
|
||||||
|
export const normalizeResponseTimes = <T, K extends keyof T>(
|
||||||
|
checks: T[],
|
||||||
|
key: K
|
||||||
|
): (T & { normalResponseTime: number })[] => {
|
||||||
|
if (!Array.isArray(checks) || checks.length === 0)
|
||||||
|
return checks as (T & {
|
||||||
|
normalResponseTime: number;
|
||||||
|
})[];
|
||||||
|
|
||||||
|
if (checks.length === 1) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...checks[0],
|
||||||
|
normalResponseTime: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVal = (check: T): number => {
|
||||||
|
const v = check[key] as unknown;
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { min, max } = checks.reduce(
|
||||||
|
(acc, check) => {
|
||||||
|
const v = getVal(check);
|
||||||
|
if (v > acc.max) acc.max = v;
|
||||||
|
if (v < acc.min) acc.min = v;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ max: -Infinity, min: Infinity }
|
||||||
|
);
|
||||||
|
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
return checks.map((check) => ({
|
||||||
|
...check,
|
||||||
|
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Interpolate color between three theme colors over 0-100 range.
|
// Interpolate color between three theme colors over 0-100 range.
|
||||||
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
|
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
|
||||||
export const getResponseColor = (
|
export const getResponseColor = (
|
||||||
@@ -85,53 +129,3 @@ export const getResponseColor = (
|
|||||||
}
|
}
|
||||||
return normalizeToHex(safe.end);
|
return normalizeToHex(safe.end);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MIN_HEIGHT_PCT = 8;
|
|
||||||
const CAP_PERCENTILE = 95;
|
|
||||||
|
|
||||||
const percentile = (sortedValues: number[], p: number): number => {
|
|
||||||
const index = (p / 100) * (sortedValues.length - 1);
|
|
||||||
const lower = Math.floor(index);
|
|
||||||
const upper = lower + 1;
|
|
||||||
const weight = index % 1;
|
|
||||||
if (upper >= sortedValues.length) return sortedValues[lower];
|
|
||||||
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeBarHeights = (
|
|
||||||
checks: { responseTime?: number | null }[]
|
|
||||||
): number[] => {
|
|
||||||
const values = checks.map((check) => {
|
|
||||||
return typeof check.responseTime === "number" && Number.isFinite(check.responseTime)
|
|
||||||
? Math.max(0, check.responseTime)
|
|
||||||
: 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sorted = values.slice().sort((a, b) => a - b);
|
|
||||||
const cap = percentile(sorted, CAP_PERCENTILE);
|
|
||||||
if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT);
|
|
||||||
return values.map((value) => {
|
|
||||||
return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeDayBarHeights = (
|
|
||||||
buckets: { date: string; avgResponseTime: number | null }[]
|
|
||||||
): Map<string, number> => {
|
|
||||||
const values = buckets.map((bucket) =>
|
|
||||||
typeof bucket.avgResponseTime === "number" && Number.isFinite(bucket.avgResponseTime)
|
|
||||||
? Math.max(0, bucket.avgResponseTime)
|
|
||||||
: 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const sorted = values.slice().sort((a, b) => a - b);
|
|
||||||
const cap = percentile(sorted, CAP_PERCENTILE);
|
|
||||||
return new Map(
|
|
||||||
buckets.map((bucket, i) => [
|
|
||||||
bucket.date,
|
|
||||||
cap <= 0
|
|
||||||
? MIN_HEIGHT_PCT
|
|
||||||
: Math.max(MIN_HEIGHT_PCT, Math.min(100, (values[i] / cap) * 100)),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,31 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Intl.NumberFormat instance for percentage formatting.
|
* Intl.NumberFormat instance for percentage formatting.
|
||||||
* Reused across all formatting calls for performance.
|
* Reused across all formatting calls for performance.
|
||||||
*
|
|
||||||
* Two decimals rather than one: at a single decimal every value above 99.95%
|
|
||||||
* renders as "100.0%", so a period containing real downtime is reported as
|
|
||||||
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
|
|
||||||
* different SLA commitments — into the same figure.
|
|
||||||
*/
|
*/
|
||||||
const percentageFormatter = new Intl.NumberFormat("en-US", {
|
const percentageFormatter = new Intl.NumberFormat("en-US", {
|
||||||
style: "percent",
|
style: "percent",
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 1,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a decimal value as a percentage string.
|
* Formats a decimal value as a percentage string.
|
||||||
* @param value - Decimal value (e.g., 0.75 for 75%)
|
* @param value - Decimal value (e.g., 0.75 for 75%)
|
||||||
* @returns Formatted percentage string (e.g., "75.00%")
|
* @returns Formatted percentage string (e.g., "75.0%")
|
||||||
* @example
|
* @example
|
||||||
* formatPercentage(0.75) // "75.00%"
|
* formatPercentage(0.75) // "75.0%"
|
||||||
* formatPercentage(1) // "100.00%"
|
* formatPercentage(1) // "100.0%"
|
||||||
* formatPercentage(0.5432) // "54.32%"
|
* formatPercentage(0.5432) // "54.3%"
|
||||||
* formatPercentage(0.9999) // "99.99%"
|
|
||||||
*/
|
*/
|
||||||
export const formatPercentage = (value: number): string => {
|
export const formatPercentage = (value: number): string => {
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
return "0.00%";
|
return "0.0%";
|
||||||
}
|
}
|
||||||
return percentageFormatter.format(value);
|
return percentageFormatter.format(value);
|
||||||
};
|
};
|
||||||
@@ -33,11 +27,11 @@ export const formatPercentage = (value: number): string => {
|
|||||||
/**
|
/**
|
||||||
* Formats a whole number percentage value as a percentage string.
|
* Formats a whole number percentage value as a percentage string.
|
||||||
* @param value - Whole number percentage (e.g., 75 for 75%)
|
* @param value - Whole number percentage (e.g., 75 for 75%)
|
||||||
* @returns Formatted percentage string (e.g., "75.00%")
|
* @returns Formatted percentage string (e.g., "75.0%")
|
||||||
* @example
|
* @example
|
||||||
* formatPercentageFromWhole(75) // "75.00%"
|
* formatPercentageFromWhole(75) // "75.0%"
|
||||||
* formatPercentageFromWhole(100) // "100.00%"
|
* formatPercentageFromWhole(100) // "100.0%"
|
||||||
* formatPercentageFromWhole(54.32) // "54.32%"
|
* formatPercentageFromWhole(54.32) // "54.3%"
|
||||||
*/
|
*/
|
||||||
export const formatPercentageFromWhole = (value: number): string => {
|
export const formatPercentageFromWhole = (value: number): string => {
|
||||||
return formatPercentage(value / 100);
|
return formatPercentage(value / 100);
|
||||||
|
|||||||
@@ -20,19 +20,8 @@ export const colors = {
|
|||||||
brandGreen: "#13715B",
|
brandGreen: "#13715B",
|
||||||
brandGreenLight: "#4DAF94",
|
brandGreenLight: "#4DAF94",
|
||||||
green200: "#ECF7F2",
|
green200: "#ECF7F2",
|
||||||
green700: "#008300",
|
|
||||||
yellow200: "#FFF4E5",
|
yellow200: "#FFF4E5",
|
||||||
yellow500: "#EDA100",
|
|
||||||
yellow600: "#C98500",
|
|
||||||
orange500: "#FFA500",
|
orange500: "#FFA500",
|
||||||
orange600: "#EB6834",
|
|
||||||
orange700: "#D95926",
|
|
||||||
blue400: "#3987E5",
|
|
||||||
blue500: "#2A78D6",
|
|
||||||
aqua500: "#1BAF7A",
|
|
||||||
aqua600: "#199E70",
|
|
||||||
magenta300: "#E87BA4",
|
|
||||||
magenta400: "#D55181",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lightPalette = {
|
export const lightPalette = {
|
||||||
@@ -49,16 +38,6 @@ export const lightPalette = {
|
|||||||
running: colors.green200,
|
running: colors.green200,
|
||||||
paused: colors.yellow200,
|
paused: colors.yellow200,
|
||||||
},
|
},
|
||||||
chart: {
|
|
||||||
phases: {
|
|
||||||
dns: colors.magenta300,
|
|
||||||
tcp: colors.orange600,
|
|
||||||
tls: colors.aqua500,
|
|
||||||
request: colors.yellow500,
|
|
||||||
firstByte: colors.blue500,
|
|
||||||
download: colors.green700,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const darkPalette = {
|
export const darkPalette = {
|
||||||
@@ -79,14 +58,4 @@ export const darkPalette = {
|
|||||||
running: alpha(colors.brandGreen, 0.18),
|
running: alpha(colors.brandGreen, 0.18),
|
||||||
paused: alpha(colors.orange500, 0.18),
|
paused: alpha(colors.orange500, 0.18),
|
||||||
},
|
},
|
||||||
chart: {
|
|
||||||
phases: {
|
|
||||||
dns: colors.magenta400,
|
|
||||||
tcp: colors.orange700,
|
|
||||||
tls: colors.aqua600,
|
|
||||||
request: colors.yellow600,
|
|
||||||
firstByte: colors.blue500,
|
|
||||||
download: colors.green700,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
@@ -16,30 +16,10 @@ declare module "@mui/material/styles" {
|
|||||||
interface Palette {
|
interface Palette {
|
||||||
sidebar: { accent: string };
|
sidebar: { accent: string };
|
||||||
rowStatus: { running: string; paused: string };
|
rowStatus: { running: string; paused: string };
|
||||||
chart: {
|
|
||||||
phases: {
|
|
||||||
dns: string;
|
|
||||||
tcp: string;
|
|
||||||
tls: string;
|
|
||||||
request: string;
|
|
||||||
firstByte: string;
|
|
||||||
download: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
interface PaletteOptions {
|
interface PaletteOptions {
|
||||||
sidebar?: { accent: string };
|
sidebar?: { accent: string };
|
||||||
rowStatus?: { running: string; paused: string };
|
rowStatus?: { running: string; paused: string };
|
||||||
chart?: {
|
|
||||||
phases: {
|
|
||||||
dns: string;
|
|
||||||
tcp: string;
|
|
||||||
tls: string;
|
|
||||||
request: string;
|
|
||||||
firstByte: string;
|
|
||||||
download: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +104,6 @@ export const theme = (mode: string, palette: any) =>
|
|||||||
disableTouchRipple: true,
|
disableTouchRipple: true,
|
||||||
disableFocusRipple: true,
|
disableFocusRipple: true,
|
||||||
},
|
},
|
||||||
styleOverrides: {
|
|
||||||
root: ({ theme }) => ({
|
|
||||||
"&.Mui-focusVisible": {
|
|
||||||
outline: `1px solid ${theme.palette.primary.main}`,
|
|
||||||
outlineOffset: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
MuiIconButton: {
|
MuiIconButton: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
|
|||||||
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user