Compare commits

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

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

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+138
View File
@@ -0,0 +1,138 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+113
View File
@@ -0,0 +1,113 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+1
View File
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
ORIGIN="localhost" ORIGIN="localhost"
LOG_LEVEL="debug" LOG_LEVEL="debug"
``` ```
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
### Client `.env` ### Client `.env`
```env ```env
+7 -5
View File
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173) - `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure) - `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL - `DB_CONNECTION_STRING`: MongoDB connection URL
- `ORIGIN`: Origin for CORS purposes - `ORIGIN`: Origin for CORS purposes (optional)
- `TOKEN_TTL`: Token time to live (in vercel/ms format) - `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
- `LOG_LEVEL`: Debug level (debug, info, warn, error) - `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`) - `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
Start the backend server: Start the backend server:
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
**Port already in use:** **Port already in use:**
- Check if another service is using ports 5173, 52345, 27017, or 6379 - Check if another service is using ports 5173, 52345, or 27017
- Stop the conflicting service or change the port in `.env` files - Stop the conflicting service or change the port in `.env` files
**MongoDB connection issues:** **MongoDB connection issues:**
+6 -13
View File
@@ -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>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="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!
+6 -2
View File
@@ -2,5 +2,9 @@ apiVersion: v2
name: checkmate-chart name: checkmate-chart
description: A Helm chart for Checkmate App description: A Helm chart for Checkmate App
type: application type: application
version: 0.3.0 version: 0.2.0
appVersion: "latest" # the all-in-one image publishes :latest on release tags; pin a version here to freeze # DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
# are never published, so a bumped chart will fail to pull.
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+3 -41
View File
@@ -19,8 +19,8 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml ### 2. Customize values.yaml
Edit `values.yaml` to update: Edit `values.yaml` to update:
- `app.ingress.host` with your domain name - `client.ingress.host` and `api.ingress.host` with your domain names
- `app.protocol` (usually http or https) - `api.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested: - **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size` - Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Add: `persistence.mongo.storageClass` (leave empty for default) - Add: `persistence.mongo.storageClass` (leave empty for default)
@@ -95,7 +95,7 @@ chart. You can still pin per tier:
```yaml ```yaml
api: api:
image: ghcr.io/bluewave-labs/checkmate # repo only image: ghcr.io/bluewave-labs/checkmate-backend # repo only
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
``` ```
@@ -244,41 +244,3 @@ kubectl get secrets | grep checkmate-tls
``` ```
The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance. The ingress will automatically use these secrets to enable HTTPS access to your Checkmate instance.
## Upgrading to chart 0.3.0 (all-in-one image)
Checkmate now ships as a single image that serves both the client and the API, so
the chart deploys one workload and one ingress instead of two.
**What changed**
| 0.2.x | 0.3.0 |
|---|---|
| `client.*` + `api.*` value blocks | one `app.*` block |
| `checkmate-client` + `checkmate-backend` images | `ghcr.io/bluewave-labs/checkmate` |
| `ghcr.io/bluewave-labs/checkmate-mongo` | upstream `mongo:8.0` |
| Deployment `checkmate-api` + `checkmate-client` | Deployment `checkmate` |
| Ingress `checkmate-api-ingress` (`/api/v1`) + client ingress (`/`) | Ingress `checkmate-ingress` (`/`) |
| `UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST` | not set — the client is same-origin |
`appVersion` is no longer frozen at v3.8.1; the chart tracks the published image again.
**Upgrading an existing install**
Existing `client.*`, `api.*` and `server.*` overrides are still read and merged over
the `app.*` defaults, so most values files upgrade without edits. Two cases fail
deliberately rather than deploying something wrong:
- `client.image` set — that image is no longer published, so the chart stops instead
of leaving you with an ImagePullBackOff. Remove the `client:` block.
- `client.ingress.host` and `api.ingress.host` set to *different* hosts — one origin
now serves both, so the chart cannot honour a split. Set a single `app.ingress.host`.
Migrate overrides to `app.*` when convenient; the legacy blocks are transitional.
**One ingress, one host**
If you previously served the client and API on separate hostnames, external clients
now use the single `app.ingress.host`. Update any bookmarks, uptime checks, or DNS
records that pointed at the old API hostname.
+11 -34
View File
@@ -1,41 +1,18 @@
{{- /* {{- /*
Back-compat shim for the all-in-one image migration. Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
still pass a legacy `server:` block keep working — this returns the effective API values with any
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
Chart 0.2.x deployed a separate client and API, configured through `client.*` and New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
`api.*` (and, before that, `server.*`). Checkmate now ships one image that serves {{- $api := include "checkmate.api" . | fromYaml -}}
both, so those collapse into a single `app.*` block. then reference `$api.*` instead of `.Values.api.*`.
Existing installs keep working: any legacy block is merged over the `app.*`
defaults, in the order client -> api -> server, so a value set in an older block
still wins. Only `image`/`tag` are deliberately NOT inherited from `client.*` —
that repo (checkmate-client) is no longer published, so honouring it would pin an
install to an image that cannot be pulled.
New installs have no legacy keys and this is a no-op deepCopy of `.Values.app`.
Consume it as:
{{- $app := include "checkmate.app" . | fromYaml -}}
*/}}
{{- define "checkmate.app" -}}
{{- $app := deepCopy .Values.app -}}
{{- with .Values.client -}}
{{- $legacy := omit (deepCopy .) "image" "tag" "port" -}}
{{- $app = mergeOverwrite $app $legacy -}}
{{- end -}}
{{- with .Values.api -}}
{{- $app = mergeOverwrite $app (deepCopy .) -}}
{{- end -}}
{{- with .Values.server -}}
{{- $app = mergeOverwrite $app (deepCopy .) -}}
{{- end -}}
{{- $app | toYaml -}}
{{- end -}}
{{- /*
Deprecated alias kept so any out-of-tree template referencing `checkmate.api`
still resolves. Prefer `checkmate.app`.
*/}} */}}
{{- define "checkmate.api" -}} {{- define "checkmate.api" -}}
{{- include "checkmate.app" . -}} {{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- end -}} {{- end -}}
{{- /* {{- /*
@@ -0,0 +1,65 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-api
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate-api
template:
metadata:
labels:
app: checkmate-api
spec:
{{- with $api.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $api.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: api
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
imagePullPolicy: {{ $api.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $api.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
{{- with $api.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -0,0 +1,39 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if $api.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-api-ingress
{{- if $api.ingress.annotations }}
annotations:
{{- range $key, $value := $api.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
{{/*#annotations:
#nginx.ingress.kubernetes.io/rewrite-target: /
#nginx.ingress.kubernetes.io/enable-cors: "true"
#nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}"
#nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS"
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec:
ingressClassName: {{ $api.ingress.className }}
{{- if $api.ingress.tls.enabled }}
tls:
- hosts:
- {{ $api.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $api.ingress.host }}
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: checkmate-api
port:
number: {{ $api.port }}
{{- end }}
@@ -0,0 +1,10 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate-api
spec:
selector:
app: checkmate-api
ports:
- port: {{ $api.port }}
@@ -1,71 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate
template:
metadata:
labels:
app: checkmate
spec:
{{- with $app.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $app.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: checkmate
image: {{ include "checkmate.image" (dict "image" $app.image "tag" $app.tag "root" $) }}
imagePullPolicy: {{ $app.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $app.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the app is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $app.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $app.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $app.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $app.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $app.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $app.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $app.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $app.probes.readiness.failureThreshold }}
{{- with $app.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
# The client is served from this same origin, so CLIENT_HOST defaults to
# the app's own ingress URL. `env` takes precedence over `envFrom`, so an
# explicit secrets.CLIENT_HOST is honoured by omitting this entry rather
# than emitting a value that would silently win over it.
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
- name: CLIENT_HOST
value: "{{ $app.protocol }}://{{ $app.ingress.host }}"
{{- end }}
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the app only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -1,35 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
{{- if $app.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-ingress
{{- if $app.ingress.annotations }}
annotations:
{{- range $key, $value := $app.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
spec:
ingressClassName: {{ $app.ingress.className }}
{{- if $app.ingress.tls.enabled }}
tls:
- hosts:
- {{ $app.ingress.host }}
secretName: {{ default (printf "%s-tls" .Release.Name) $app.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $app.ingress.host }}
http:
paths:
# The application serves both the client and /api/v1 from one origin, so
# a single root rule replaces the separate client and API ingresses. No
# CORS annotations are needed now that the browser is same-origin.
- path: /
pathType: Prefix
backend:
service:
name: checkmate
port:
number: {{ $app.port }}
{{- end }}
@@ -1,10 +0,0 @@
{{- $app := include "checkmate.app" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate
spec:
selector:
app: checkmate
ports:
- port: {{ $app.port }}
@@ -0,0 +1,46 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-client
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-client
template:
metadata:
labels:
app: checkmate-client
spec:
{{- with .Values.client.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.client.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: client
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
ports:
- containerPort: {{ .Values.client.port }}
env:
- name: UPTIME_APP_API_BASE_URL
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts:
- name: config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
{{- with .Values.client.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
volumes:
- name: config-volume
configMap:
name: checkmate-client-nginx-cm
@@ -0,0 +1,31 @@
{{- if .Values.client.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-client-ingress
{{- if .Values.client.ingress.annotations }}
annotations:
{{- range $key, $value := .Values.client.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
spec:
ingressClassName: {{ .Values.client.ingress.className }}
{{- if .Values.client.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.client.ingress.host }}
secretName: {{ default (printf "%s-client-tls" .Release.Name) .Values.client.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.client.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: checkmate-client
port:
number: {{ .Values.client.port }}
{{- end }}
@@ -0,0 +1,42 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: checkmate-client-nginx-cm
data:
default.conf: |
server {
listen 80;
listen [::]:80;
server_name checkmate-demo.bluewavelabs.ca;
server_tokens off;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# location /api/ {
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
location /api-docs/ {
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-client
spec:
selector:
app: checkmate-client
ports:
- port: {{ .Values.client.port }}
+36 -32
View File
@@ -1,45 +1,49 @@
{{- $app := include "checkmate.app" . | fromYaml -}} {{- $api := include "checkmate.api" . | fromYaml -}}
{{- if eq $app.ingress.host "change_me" }} {{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "app.ingress.host must be overridden and not set to 'change_me'" }} {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }} {{- end }}
{{- $protocol := $app.protocol }} {{- if eq $api.ingress.host "change_me" }}
{{- if not (or (eq $protocol "http") (eq $protocol "https")) }} {{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
{{- fail "app.protocol must be either 'http' or 'https'" }} {{- end }}
{{- $apiProtocol := $api.protocol }}
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
{{- fail "api.protocol must be either 'http' or 'https'" }}
{{- end }}
{{- $clientProtocol := .Values.client.protocol }}
{{- if not (or (eq $clientProtocol "http") (eq $clientProtocol "https")) }}
{{- fail "client.protocol must be either 'http' or 'https'" }}
{{- end }} {{- end }}
{{/* Enforce protocol when TLS is enabled to avoid mixed-content */}} {{/* Enforce protocol when TLS is enabled to avoid mixed-content */}}
{{- if and $app.ingress.tls.enabled (ne $protocol "https") }} {{- if and .Values.client.ingress.tls.enabled (ne $clientProtocol "https") }}
{{- fail "app.ingress.tls.enabled is true but app.protocol is not 'https'. Set app.protocol: https to avoid mixed content." }} {{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
{{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
{{- end }} {{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}} {{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $ann := $app.ingress.annotations | default dict }} {{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $hasIssuer := or (hasKey $ann "cert-manager.io/cluster-issuer") (hasKey $ann "cert-manager.io/issuer") }} {{- $aAnn := $api.ingress.annotations | default dict }}
{{- if and $app.ingress.tls.enabled (not $hasIssuer) }} {{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- fail "app.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }} {{- end }}
{{/* {{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
Upgrade guard. The client and API are one service now, so a values file that still {{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
points the two at different hosts cannot be honoured — fail loudly rather than
silently dropping one of them.
*/}}
{{- if and .Values.client .Values.api }}
{{- $clientHost := (.Values.client.ingress | default dict).host }}
{{- $apiHost := (.Values.api.ingress | default dict).host }}
{{- if and $clientHost $apiHost (ne $clientHost $apiHost) }}
{{- fail (printf "client.ingress.host (%s) and api.ingress.host (%s) differ, but Checkmate now serves the client and the API from one origin. Set a single app.ingress.host instead." $clientHost $apiHost) }}
{{- end }}
{{- end }}
{{/*
The client image is no longer published. Point this out rather than letting the
install fail later on an ImagePullBackOff nobody can action.
*/}}
{{- if and .Values.client .Values.client.image }}
{{- fail "client.image is set, but the separate client image is no longer published. Remove the client block; the application image in app.image now serves the client." }}
{{- end }} {{- end }}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}} {{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
@@ -47,4 +51,4 @@
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }} {{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
{{- end }} {{- end }}
{{/* Secret name can be omitted; we default to <release>-tls in templates */}} {{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
@@ -1,4 +1,4 @@
{{- $app := include "checkmate.app" . | fromYaml -}} {{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }} {{- if .Values.worker.enabled }}
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
@@ -27,8 +27,8 @@ spec:
{{- end }} {{- end }}
containers: containers:
- name: worker - name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $app.image) "tag" (.Values.worker.tag | default $app.tag) "root" $) }} image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $app.imagePullPolicy }} imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
ports: ports:
- name: health - name: health
containerPort: {{ .Values.worker.healthPort }} containerPort: {{ .Values.worker.healthPort }}
@@ -36,13 +36,9 @@ spec:
- secretRef: - secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv) name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env: env:
# validateEnv requires a URL-shaped CLIENT_HOST, so mirror the app's default # Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
# here. Omitted when secrets.CLIENT_HOST is set, since `env` would otherwise
# take precedence over the envFrom value and silently discard the override.
{{- if not (hasKey .Values.secrets "CLIENT_HOST") }}
- name: CLIENT_HOST - name: CLIENT_HOST
value: "{{ $app.protocol }}://{{ $app.ingress.host }}" value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
{{- end }}
- name: QUEUE_MODE - name: QUEUE_MODE
value: "worker" value: "worker"
- name: HEALTH_PORT - name: HEALTH_PORT
+35 -24
View File
@@ -1,17 +1,31 @@
# Checkmate is published as a single all-in-one application image: the API server client:
# also serves the React client, so one deployment and one ingress cover both. image: ghcr.io/bluewave-labs/checkmate-client
# The `client:` and `api:` blocks from chart 0.2.x are still read for back-compat tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
# (see templates/_helpers.tpl) — `app.*` is the block to set on new installs. imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
app: port: 80
image: ghcr.io/bluewave-labs/checkmate protocol: http
ingress:
enabled: true
host: change_me
className: nginx
annotations: {}
# Example annotations for cert-manager:
# annotations:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-client-tls # Optional; defaults to <release>-client-tls if omitted
# Note: when enabling TLS, also set client.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
api:
image: ghcr.io/bluewave-labs/checkmate-backend
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 52345 port: 52345
protocol: http protocol: http
resources: {} # Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
affinity: {}
tolerations: {}
# Liveness/readiness probes hit GET /api/v1/health on the app port (proves the HTTP server is up).
probes: probes:
liveness: liveness:
initialDelaySeconds: 10 initialDelaySeconds: 10
@@ -33,27 +47,25 @@ app:
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls: tls:
enabled: false enabled: false
# secretName: {{ .Release.Name }}-tls # Optional; defaults to <release>-tls if omitted # secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
# Note: when enabling TLS, also set app.protocol: https and add # Note: when enabling TLS, also set api.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
# Upstream MongoDB. The former ghcr.io/bluewave-labs/checkmate-mongo image is no
# longer published; this matches the mongo version in docker/docker-compose.yaml.
mongodb: mongodb:
image: mongo image: ghcr.io/bluewave-labs/checkmate-mongo
tag: "8.0" tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 27017 port: 27017
worker: worker:
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all # Off by default; existing installs are unchanged. When true, a dedicated worker processes all
# jobs and the app stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the # jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# app is all-in-one and processes jobs itself. # API is all-in-one and processes jobs itself.
enabled: false enabled: false
image: "" # defaults to app.image when empty, same as the application image image: "" # defaults to api.image when empty, same as API image
tag: "" # defaults to app.tag (-> Chart.appVersion) when empty tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to app.imagePullPolicy when empty imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
replicas: 1 # used only when autoscaling.enabled = false replicas: 1 # used only when autoscaling.enabled = false
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
@@ -85,8 +97,7 @@ worker:
secrets: secrets:
JWT_SECRET: change_me JWT_SECRET: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
# CLIENT_HOST is derived from app.protocol + app.ingress.host by the deployment; CLIENT_HOST: change_me
# set it here only to override that (e.g. when fronting the app with another proxy).
NODE_ENV: production NODE_ENV: production
LOG_LEVEL: info LOG_LEVEL: info
TOKEN_TTL: 99d TOKEN_TTL: 99d
+2 -2
View File
@@ -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
View File
@@ -1,10 +1,9 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.11.0", "version": "3.9.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development", "build-dev": "tsc -b && vite build --mode development",
@@ -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>
+4 -14
View File
@@ -1,28 +1,18 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps { interface DotProps {
color?: string; color?: string;
size?: DotSize; size?: string;
style?: CSSProperties; style?: CSSProperties;
} }
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => { export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return ( return (
<span <span
style={{ style={{
content: '""', content: '""',
width: DOT_SIZE_PX[size], width: size,
height: DOT_SIZE_PX[size], height: size,
borderRadius: "50%", borderRadius: "50%",
backgroundColor: color, backgroundColor: color,
opacity: 0.8, opacity: 0.8,
@@ -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>
</> </>
)} )}
+1 -11
View File
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type AutoCompleteInputProps = Omit< type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>, AutocompleteProps<any, boolean, boolean, boolean>,
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode; renderOptionContent?: (option: any) => React.ReactNode;
placeholder?: string;
error?: boolean;
helperText?: string;
}; };
export const AutoCompleteInput = ({ export const AutoCompleteInput = ({
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
required, required,
renderInput, renderInput,
renderOptionContent, renderOptionContent,
placeholder,
error,
helperText,
...props ...props
}: AutoCompleteInputProps) => { }: AutoCompleteInputProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation();
const multiple = props.multiple; const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props; const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => ( const defaultRenderInput = (params: any) => (
<TextField <TextField
{...params} {...params}
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")} placeholder="Type to search"
error={error}
helperText={helperText}
/> />
); );
-3
View File
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
"& .MuiButtonBase-root": { "& .MuiButtonBase-root": {
mr: theme.spacing(6), mr: theme.spacing(6),
}, },
"&:has(.Mui-focusVisible)": {
backgroundColor: theme.palette.action.hover,
},
}} }}
/> />
); );
+4 -15
View File
@@ -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>
); );
} }
+12 -21
View File
@@ -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
/>
)}
/>
);
};
+9 -7
View File
@@ -18,13 +18,15 @@ const RootLayout = () => {
flex={1} flex={1}
padding={6} padding={6}
overflow={"hidden"} overflow={"hidden"}
bgcolor={ sx={{
theme.palette.mode === "dark" backgroundColor:
? "rgba(255, 255, 255, 0.01)" theme.palette.mode === "dark"
: "rgba(124, 116, 116, 0.01)" ? "rgba(255, 255, 255, 0.01)"
} : "rgba(0, 0, 0, 0.01)",
alignItems="center" display: "flex",
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12} alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
> >
<Stack <Stack
maxWidth={1280} maxWidth={1280}
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode"; import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { formatMs } from "@/Utils/TimeUtils";
interface GeoChecksMapProps { interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[]; geoChecks: FlatGeoCheck[];
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
return status ? theme.palette.success.main : theme.palette.error.main; return status ? theme.palette.success.main : theme.palette.error.main;
}; };
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return ( return (
<> <>
{mapPopupStyles} {mapPopupStyles}
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
> >
Response Time: Response Time:
</Typography>{" "} </Typography>{" "}
{formatMs(selectedCheck.timings.total)} {formatResponseTime(selectedCheck.timings.total)}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
onChange={(event) => {
c.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={c.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -1,24 +1,22 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements"; import { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor"; import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils"; import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
interface MonitorStatBoxesProps { interface MonitorStatBoxesProps {
monitor?: Monitor; monitor?: Monitor;
monitorStats: MonitorStats | null; monitorStats: MonitorStats | null;
certificateExpiry?: string; certificateExpiry?: string;
domainExpiry?: string;
} }
export const MonitorStatBoxes = ({ export const MonitorStatBoxes = ({
monitor, monitor,
monitorStats, monitorStats,
certificateExpiry, certificateExpiry,
domainExpiry,
}: MonitorStatBoxesProps) => { }: MonitorStatBoxesProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0; const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure); const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const lastCheckTime = formatDuration(timeSinceLastCheck); const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive = const isActive =
monitor?.status === "up" || monitor?.status === "up" ||
monitor?.status === "paused" || monitor?.status === "paused" ||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
/> />
<StatBox <StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")} title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)} subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/> />
{monitor?.type === "http" && ( {monitor?.type === "http" && (
<> <StatBox
<StatBox title={t("pages.common.monitors.statBoxes.certificateExpiry")}
title={t("pages.common.monitors.statBoxes.certificateExpiry")} subtitle={certificateExpiry || "N/A"}
subtitle={certificateExpiry || "N/A"} />
/>
<StatBox
title={t("pages.common.monitors.statBoxes.domainExpiry")}
subtitle={domainExpiry || "N/A"}
/>
</>
)} )}
</Stack> </Stack>
); );
@@ -1,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>
);
};
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure"; export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary"; export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar"; export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+9 -21
View File
@@ -10,11 +10,10 @@ import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react"; import { MoreVertical, LogOut } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements"; import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js"; import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js"; import type { RootState } from "@/Types/state.js";
import { setMode } from "@/Features/UI/uiSlice.js";
interface AuthFooterProps { interface AuthFooterProps {
collapsed: boolean; collapsed: boolean;
@@ -28,7 +27,6 @@ interface AuthFooterProps {
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => { export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const user = useSelector((state: RootState) => state.auth.user); const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Typography <Typography
fontWeight={500} fontWeight={500}
noWrap noWrap
color={theme.palette.text.primary} sx={{ color: theme.palette.text.primary }}
> >
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Typography> </Typography>
<Typography <Typography
noWrap noWrap
textTransform={"capitalize"} sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
color={theme.palette.text.secondary}
> >
{getRoleText()} {getRoleText()}
</Typography> </Typography>
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
pb={2} pb={2}
sx={{ pointerEvents: "none" }} sx={{ pointerEvents: "none" }}
> >
<Typography fontWeight={500}> <Typography
fontWeight={500}
fontSize={13}
>
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Typography> </Typography>
<Typography <Typography
variant="body2" fontSize={12}
textTransform={"capitalize"} sx={{ textTransform: "capitalize" }}
> >
{getRoleText()} {getRoleText()}
</Typography> </Typography>
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
{item.name} {item.name}
</MenuItem> </MenuItem>
))} ))}
<MenuItem
onClick={() => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
handleMenuClose();
}}
sx={menuItemSx}
>
<Icon icon={mode === "light" ? Moon : Sun} />
{mode === "light"
? t("pages.settings.form.ui.option.theme.dark")
: t("pages.settings.form.ui.option.theme.light")}
</MenuItem>
<MenuItem <MenuItem
onClick={handleLogout} onClick={handleLogout}
sx={menuItemSx} sx={menuItemSx}
+1 -6
View File
@@ -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",
-1
View File
@@ -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
View File
@@ -2,13 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line"; type ChartType = "histogram" | "line";
type TableName = type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed";
interface TableState { interface TableState {
rowsPerPage: number; rowsPerPage: number;
@@ -20,7 +14,6 @@ interface SidebarState {
interface UIState { interface UIState {
monitors: TableState; monitors: TableState;
pagespeed: TableState;
team: TableState; team: TableState;
maintenance: TableState; maintenance: TableState;
infrastructure: TableState; infrastructure: TableState;
@@ -44,9 +37,6 @@ const initialState: UIState = {
monitors: { monitors: {
rowsPerPage: 10, rowsPerPage: 10,
}, },
pagespeed: {
rowsPerPage: 10,
},
team: { team: {
rowsPerPage: 5, rowsPerPage: 5,
}, },
+3 -12
View File
@@ -1,11 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions { interface UseMonitorFormOptions {
@@ -47,10 +42,8 @@ export const getMonitorDefaults = (
url: data?.url || "", url: data?.url || "",
method: data?.method ?? DefaultHttpMethod, method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false, ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false, useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod, matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "", expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "", jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [], customUpCodes: data?.customUpCodes || [],
@@ -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: [],
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
+80 -8
View File
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
} }
function buildDefaults(data: Notification | null): NotificationFormData { function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return { return {
type: data?.type ?? "email", type: "email",
notificationName: data?.notificationName || "", notificationName: data?.notificationName || "",
address: data?.address || "", address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
}; };
} }
-22
View File
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
-2
View File
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "", pagespeedApiKey: "",
systemEmailPassword: "", systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
}; };
return { schema: settingsSchema, defaults }; return { schema: settingsSchema, defaults };
+103 -39
View File
@@ -1,6 +1,11 @@
import { TextField, Button } from "@/Components/inputs"; import { Trash2 } from "lucide-react";
import { TextField, Button, Autocomplete } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { FormProvider, useForm } from "react-hook-form"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import FormHelperText from "@mui/material/FormHelperText";
import { useForm, Controller } from "react-hook-form";
import { ConfigBox, BasePage } from "@/Components/design-elements"; import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User"; import { UserRoles } from "@/Types/User";
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
interface RoleOption { interface RoleOption {
id: UserRole; id: UserRole;
@@ -40,11 +43,10 @@ const EditUserPage = () => {
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`); const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
const form = useForm<EditUserFormData>({ const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, reset } = form;
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -62,6 +64,13 @@ const EditUserPage = () => {
name: t(`common.auth.roles.${role}`), name: t(`common.auth.roles.${role}`),
})); }));
const watchedRoles = watch("role");
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
const handleRemoveRole = (roleToRemove: UserRole) => {
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser = const canDeleteUser =
isAdmin && isAdmin &&
userId !== currentUser?.id && userId !== currentUser?.id &&
@@ -81,31 +90,42 @@ const EditUserPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage loading={isLoading}>
<BasePage <form onSubmit={handleSubmit(onSubmit)}>
loading={isLoading}
component={"form"}
onSubmit={handleSubmit(onSubmit)}
>
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<ConfigBox <ConfigBox
title={t("pages.account.form.name.title")} title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")} subtitle={t("pages.account.form.name.description")}
rightContent={ rightContent={
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<FormTextField <Controller
name="firstName" name="firstName"
autoComplete="given-name" control={control}
fieldLabel={t("common.form.name.option.firstName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.firstName.placeholder")} <TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="given-name"
/>
)}
/> />
<FormTextField <Controller
name="lastName" name="lastName"
autoComplete="family-name" control={control}
fieldLabel={t("common.form.name.option.lastName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.lastName.placeholder")} <TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="family-name"
/>
)}
/> />
<TextField <TextField
fieldLabel={t("common.form.email.option.email.label")} fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")} placeholder={t("common.form.email.option.email.placeholder")}
@@ -119,11 +139,55 @@ const EditUserPage = () => {
title={t("pages.editUser.form.roles.title")} title={t("pages.editUser.form.roles.title")}
subtitle={t("pages.editUser.form.roles.description")} subtitle={t("pages.editUser.form.roles.description")}
rightContent={ rightContent={
<FormMultiSelectField <Stack spacing={theme.spacing(4)}>
name="role" <Controller
fieldLabel={t("common.form.role.option.role.label")} name="role"
options={roleOptions} control={control}
/> render={({ field, fieldState }) => (
<>
<Autocomplete
fieldLabel={t("common.form.role.option.role.label")}
multiple
options={roleOptions}
value={selectedRoles}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: RoleOption[]) => {
field.onChange(newValue.map((r) => r.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{selectedRoles.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedRoles.map((role, index) => (
<Stack
direction="row"
alignItems="center"
key={role.id}
width="100%"
>
<Typography flexGrow={1}>{role.name}</Typography>
<IconButton
size="small"
onClick={() => handleRemoveRole(role.id)}
aria-label="Remove role"
>
<Trash2 size={16} />
</IconButton>
{index < selectedRoles.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
} }
/> />
<Stack <Stack
@@ -153,19 +217,19 @@ const EditUserPage = () => {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
<DialogInput </form>
open={showDeleteDialog} <DialogInput
title={t("pages.editUser.dialog.removeUser.title")} open={showDeleteDialog}
content={t("pages.editUser.dialog.removeUser.content", { title={t("pages.editUser.dialog.removeUser.title")}
name: `${user?.firstName} ${user?.lastName}`, content={t("pages.editUser.dialog.removeUser.content", {
})} name: `${user?.firstName} ${user?.lastName}`,
onCancel={() => setShowDeleteDialog(false)} })}
onConfirm={handleDeleteUser} onCancel={() => setShowDeleteDialog(false)}
confirmText={t("common.buttons.removeUser")} onConfirm={handleDeleteUser}
loading={isDeleting} confirmText={t("common.buttons.removeUser")}
/> loading={isDeleting}
</BasePage> />
</FormProvider> </BasePage>
); );
}; };
+82 -61
View File
@@ -1,15 +1,13 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements"; import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button } from "@/Components/inputs";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { usePasswordForm } from "@/Hooks/usePasswordForm"; import { usePasswordForm } from "@/Hooks/usePasswordForm";
import { usePatch } from "@/Hooks/UseApi"; import { usePatch } from "@/Hooks/UseApi";
import type { PasswordFormData } from "@/Validation/password"; import type { PasswordFormData } from "@/Validation/password";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabPassword = () => { export const TabPassword = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,11 +15,10 @@ export const TabPassword = () => {
const { resolver, defaults } = usePasswordForm(); const { resolver, defaults } = usePasswordForm();
const { patch, loading } = usePatch<FormData, void>(); const { patch, loading } = usePatch<FormData, void>();
const form = useForm<PasswordFormData>({ const { control, handleSubmit, reset } = useForm<PasswordFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, reset } = form;
const onSubmit = async (data: PasswordFormData) => { const onSubmit = async (data: PasswordFormData) => {
const fd = new FormData(); const fd = new FormData();
@@ -35,61 +32,85 @@ export const TabPassword = () => {
}; };
return ( return (
<FormProvider {...form}> <Stack
<Stack gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.account.form.currentPassword.title")}
title={t("pages.account.form.currentPassword.title")} subtitle={t("pages.account.form.currentPassword.description")}
subtitle={t("pages.account.form.currentPassword.description")} rightContent={
rightContent={ <Controller
<FormTextField name="currentPassword"
name="currentPassword" control={control}
type="password" render={({ field, fieldState }) => (
autoComplete="current-password" <TextField
fieldLabel={t("pages.account.form.currentPassword.option.label")} {...field}
placeholder={t("pages.account.form.currentPassword.option.placeholder")} type="password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
autoComplete="current-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="newPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.account.form.newPassword.option.newPassword.label"
)}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/> />
} <Controller
/> name="confirm"
<ConfigBox control={control}
title={t("pages.account.form.newPassword.title")} render={({ field, fieldState }) => (
subtitle={t("pages.account.form.newPassword.description")} <TextField
rightContent={ {...field}
<Stack gap={theme.spacing(LAYOUT.MD)}> type="password"
<FormTextField fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
name="newPassword" placeholder={t(
type="password" "pages.account.form.newPassword.option.confirm.placeholder"
autoComplete="new-password" )}
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")} autoComplete="new-password"
placeholder={t( error={!!fieldState.error}
"pages.account.form.newPassword.option.newPassword.placeholder" helperText={fieldState.error?.message ?? ""}
)} />
/> )}
<FormTextField />
name="confirm" </Stack>
type="password" }
autoComplete="new-password" />
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")} <Button
placeholder={t( type="submit"
"pages.account.form.newPassword.option.confirm.placeholder" variant="contained"
)} color="primary"
/> loading={loading}
</Stack> sx={{ alignSelf: "flex-end", minWidth: 100 }}
} >
/> {t("common.buttons.save")}
<Button </Button>
type="submit" </Stack>
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
); );
}; };
+64 -52
View File
@@ -1,11 +1,11 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements"; import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button } from "@/Components/inputs";
import { ImageUpload } from "@/Components/inputs"; import { ImageUpload } from "@/Components/inputs";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { useProfileForm } from "@/Hooks/useProfileForm"; import { useProfileForm } from "@/Hooks/useProfileForm";
import { usePatch } from "@/Hooks/UseApi"; import { usePatch } from "@/Hooks/UseApi";
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
import type { ProfileFormData } from "@/Validation/profile"; import type { ProfileFormData } from "@/Validation/profile";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import type { User } from "@/Types/User"; import type { User } from "@/Types/User";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabProfile = () => { export const TabProfile = () => {
const theme = useTheme(); const theme = useTheme();
@@ -24,7 +22,7 @@ export const TabProfile = () => {
const { resolver, defaults } = useProfileForm(); const { resolver, defaults } = useProfileForm();
const { patch, loading: patchLoading } = usePatch<FormData, User>(); const { patch, loading: patchLoading } = usePatch<FormData, User>();
const form = useForm<ProfileFormData>({ const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
resolver, resolver,
defaultValues: { defaultValues: {
firstName: user?.firstName ?? defaults.firstName, firstName: user?.firstName ?? defaults.firstName,
@@ -33,7 +31,7 @@ export const TabProfile = () => {
deleteProfileImage: defaults.deleteProfileImage, deleteProfileImage: defaults.deleteProfileImage,
}, },
}); });
const { handleSubmit, setValue, watch } = form;
const currentImage = watch("profileImage"); const currentImage = watch("profileImage");
const deleteImage = watch("deleteProfileImage"); const deleteImage = watch("deleteProfileImage");
@@ -76,52 +74,66 @@ export const TabProfile = () => {
}; };
return ( return (
<FormProvider {...form}> <Stack
<Stack gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.account.form.name.title")}
title={t("pages.account.form.name.title")} subtitle={t("pages.account.form.name.description")}
subtitle={t("pages.account.form.name.description")} rightContent={
rightContent={ <Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(LAYOUT.MD)}> <Controller
<FormTextField name="firstName"
name="firstName" control={control}
autoComplete="given-name" render={({ field, fieldState }) => (
fieldLabel={t("common.form.name.option.firstName.label")} <TextField
placeholder={t("common.form.name.option.firstName.placeholder")} {...field}
/> fieldLabel={t("common.form.name.option.firstName.label")}
<FormTextField placeholder={t("common.form.name.option.firstName.placeholder")}
name="lastName" autoComplete="given-name"
autoComplete="family-name" error={!!fieldState.error}
fieldLabel={t("common.form.name.option.lastName.label")} helperText={fieldState.error?.message ?? ""}
placeholder={t("common.form.name.option.lastName.placeholder")} />
/> )}
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/> />
} <Controller
/> name="lastName"
<Button control={control}
type="submit" render={({ field, fieldState }) => (
variant="contained" <TextField
color="primary" {...field}
loading={patchLoading} fieldLabel={t("common.form.name.option.lastName.label")}
sx={{ alignSelf: "flex-end", minWidth: 100 }} placeholder={t("common.form.name.option.lastName.placeholder")}
> autoComplete="family-name"
{t("common.buttons.save")} error={!!fieldState.error}
</Button> helperText={fieldState.error?.message ?? ""}
</Stack> />
</FormProvider> )}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
); );
}; };
@@ -1,16 +1,15 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Dialog } from "@/Components/inputs"; import { Dialog, TextField, Select } from "@/Components/inputs";
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { User } from "@/Types/User"; import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
interface AddTeamMemberDialogProps { interface AddTeamMemberDialogProps {
open: boolean; open: boolean;
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
const { schema, defaults } = useAddTeamMemberForm(); const { schema, defaults } = useAddTeamMemberForm();
const { post, loading } = usePost<RegisterPayload, User>(); const { post, loading } = usePost<RegisterPayload, User>();
const form = useForm<AddTeamMemberFormData>({ const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
values: defaults, values: defaults,
}); });
const { handleSubmit, reset, setError } = form; const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => { const handleClose = () => {
reset(); reset();
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
const onSubmit = async (data: AddTeamMemberFormData) => { const onSubmit = async (data: AddTeamMemberFormData) => {
if (loading) return; if (loading) return;
const { confirm: _confirm, ...userData } = data; const { confirm, ...userData } = data;
const payload: RegisterPayload = userData; const payload: RegisterPayload = userData;
const result = await post("/auth/users", payload); const result = await post("/auth/users", payload);
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
}; };
return ( return (
<FormProvider {...form}> <Dialog
<Dialog open={open}
open={open} title={t("pages.account.team.addMember.title")}
title={t("pages.account.team.addMember.title")} content={t("pages.account.team.addMember.description")}
content={t("pages.account.team.addMember.description")} onCancel={handleClose}
onCancel={handleClose} onConfirm={handleSubmit(onSubmit)}
onConfirm={handleSubmit(onSubmit)} confirmText={t("common.buttons.addMember")}
confirmText={t("common.buttons.addMember")} loading={loading}
loading={loading} maxWidth="sm"
maxWidth="sm" fullWidth
fullWidth >
> <Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Stack
<Stack direction="row"
direction="row" gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(LAYOUT.XS)} >
> <Controller
<FormTextField name="firstName"
name="firstName" control={control}
fieldLabel={t("common.form.name.option.firstName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.firstName.placeholder")} <TextField
/> {...field}
<FormTextField fieldLabel={t("common.form.name.option.firstName.label")}
name="lastName" placeholder={t("common.form.name.option.firstName.placeholder")}
fieldLabel={t("common.form.name.option.lastName.label")} error={!!fieldState.error}
placeholder={t("common.form.name.option.lastName.placeholder")} helperText={fieldState.error?.message ?? ""}
/> sx={{ flex: 1 }}
</Stack> />
<FormTextField )}
name="email" />
type="email" <Controller
fieldLabel={t("common.form.email.option.email.label")} name="lastName"
placeholder={t("common.form.email.option.email.placeholder")} control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/> />
<RoleSelectField />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
sx={{ flex: 1 }}
/>
<FormTextField
name="confirm"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
/>
</Stack>
</Stack> </Stack>
</Dialog> <Controller
</FormProvider> name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
); );
}; };
@@ -1,16 +1,16 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { useTheme, Typography } from "@mui/material"; import MenuItem from "@mui/material/MenuItem";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Button } from "@/Components/inputs"; import { Dialog, TextField, Select, Button } from "@/Components/inputs";
import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm"; import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite"; import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig"; import { runtimeConfig } from "@/Utils/runtimeConfig";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
@@ -31,12 +31,11 @@ export const InviteTeamMemberDialog = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { resolver, defaults } = useInviteForm(); const { resolver, defaults } = useInviteForm();
const form = useForm<InviteFormData>({ const { control, handleSubmit, reset } = useForm<InviteFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
values: defaults, values: defaults,
}); });
const { handleSubmit, reset } = form;
const { post: generateToken, loading: generateLoading } = usePost< const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData, InviteFormData,
@@ -48,6 +47,12 @@ export const InviteTeamMemberDialog = ({
>(); >();
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => { const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data); const result = await generateToken("/invite", data);
if (result?.data?.token) { if (result?.data?.token) {
@@ -71,55 +76,88 @@ export const InviteTeamMemberDialog = ({
}; };
return ( return (
<FormProvider {...form}> <Dialog
<Dialog open={open}
open={open} title={t("pages.account.team.invite.title")}
title={t("pages.account.team.invite.title")} content={t("pages.account.team.invite.description")}
content={t("pages.account.team.invite.description")} onCancel={handleClose}
onCancel={handleClose} onConfirm={handleSubmit(handleSendInvite)}
onConfirm={handleSubmit(handleSendInvite)} confirmText={t("common.buttons.sendInvite")}
confirmText={t("common.buttons.sendInvite")} loading={sendLoading || generateLoading}
loading={sendLoading || generateLoading} maxWidth="sm"
maxWidth="sm" fullWidth
fullWidth additionalButtons={
additionalButtons={ <Button
<Button variant="contained"
variant="contained" color="primary"
color="primary" onClick={handleSubmit(handleGenerateToken)}
onClick={handleSubmit(handleGenerateToken)} loading={generateLoading || sendLoading}
loading={generateLoading || sendLoading} >
> {t("common.buttons.generateToken")}
{t("common.buttons.generateToken")} </Button>
</Button> }
} >
> <Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Controller
<FormTextField name="email"
name="email" control={control}
type="email" render={({ field, fieldState }) => (
fieldLabel={t("common.form.email.option.email.label")} <TextField
placeholder={t("common.form.email.option.email.placeholder")} {...field}
/> fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
<RoleSelectField /> type="email"
{inviteLink && ( fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<> <>
<Typography variant="body2"> <Select
{t("pages.account.team.invite.linkLabel")} {...field}
</Typography> value={Array.isArray(value) ? (value[0] ?? "") : ""}
<TextField onChange={(e) => onChange([e.target.value as UserRole])}
value={inviteLink} fieldLabel={t("common.form.role.option.role.label")}
fullWidth fullWidth
slotProps={{ error={!!fieldState.error}
input: { >
readOnly: true, {roleOptions.map((option) => (
}, <MenuItem
}} key={option.value}
/> value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</> </>
)} )}
</Stack> />
</Dialog> {inviteLink && (
</FormProvider> <>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
); );
}; };
@@ -1,45 +0,0 @@
import MenuItem from "@mui/material/MenuItem";
import FormHelperText from "@mui/material/FormHelperText";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Select } from "@/Components/inputs";
import { UserRoles } from "@/Types/User";
import type { UserRole } from "@/Types/User";
export const RoleSelectField = () => {
const { t } = useTranslation();
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
name: "role",
});
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
value: role,
label: t(`common.auth.roles.${role}`),
}));
return (
<>
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
);
};
+49 -38
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm"; import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login"; import type { LoginFormData } from "@/Validation/login";
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi"; import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const LoginPage = () => { const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,13 +31,11 @@ const LoginPage = () => {
const { schema, defaults } = useLoginForm(); const { schema, defaults } = useLoginForm();
const form = useForm<LoginFormData>({ const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit } = form;
if (isCheckingAdmin) return null; if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => { const onSubmit = async (data: LoginFormData) => {
@@ -53,39 +50,53 @@ const LoginPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.login.title")}
title={t("pages.auth.login.title")} subtitle={t("pages.auth.login.subtitle")}
subtitle={t("pages.auth.login.subtitle")} >
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
<FormTextField {t("pages.auth.login.submit")}
name="email" </Button>
fieldLabel={t("pages.auth.common.form.option.email.label")} <TextLink
placeholder={t("pages.auth.common.form.option.email.placeholder")} alignSelf={"center"}
/> text={t("pages.auth.login.links.forgotPassword.text")}
<FormTextField linkText={t("pages.auth.login.links.forgotPassword.linkText")}
name="password" href="/forgot-password"
type="password" />
fieldLabel={t("pages.auth.common.form.option.password.label")} </BaseAuthPage>
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+35 -31
View File
@@ -1,13 +1,12 @@
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery"; import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const ForgotPasswordPage = () => { const ForgotPasswordPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
const { schema, defaults } = useRecoveryForm(); const { schema, defaults } = useRecoveryForm();
const form = useForm<RecoveryFormData>({ const { control, handleSubmit } = useForm<RecoveryFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit } = form;
const onSubmit = async (data: RecoveryFormData) => { const onSubmit = async (data: RecoveryFormData) => {
if (loading) return; if (loading) return;
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.forgotPassword.title")}
title={t("pages.auth.forgotPassword.title")} subtitle={t("pages.auth.forgotPassword.subtitle")}
subtitle={t("pages.auth.forgotPassword.subtitle")} >
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
<FormTextField {t("pages.auth.forgotPassword.submit")}
name="email" </Button>
fieldLabel={t("pages.auth.common.form.option.email.label")} <TextLink
placeholder={t("pages.auth.common.form.option.email.placeholder")} alignSelf={"center"}
/> text={t("pages.auth.forgotPassword.links.login.text")}
<Button linkText={t("pages.auth.forgotPassword.links.login.linkText")}
variant="contained" href="/login"
type="submit" />
loading={loading} </BaseAuthPage>
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+103 -70
View File
@@ -1,7 +1,7 @@
import { BaseAuthPage } from "@/Components/design-elements"; import { BaseAuthPage } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert"; import Alert from "@mui/material/Alert";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm"; import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register"; import type { RegisterFormData } from "@/Validation/register";
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User"; import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
interface RegisterPayload { interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">; user: Omit<RegisterFormData, "confirm">;
@@ -46,11 +45,10 @@ const RegisterPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const form = useForm<RegisterFormData>({ const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, setError, reset } = form;
useEffect(() => { useEffect(() => {
if (!token || hasVerified.current) return; if (!token || hasVerified.current) return;
@@ -66,14 +64,14 @@ const RegisterPage = () => {
navigate("/register", { replace: true }); navigate("/register", { replace: true });
} }
}); });
}, [token, defaults, navigate, reset, verifyToken]); }, [token]);
if (isCheckingAdmin) return null; if (isCheckingAdmin) return null;
const onSubmit = async (data: RegisterFormData) => { const onSubmit = async (data: RegisterFormData) => {
if (loading) return; if (loading) return;
const { confirm: _confirm, ...userData } = data; const { confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData }; const payload: RegisterPayload = { user: userData };
if (token) { if (token) {
payload.token = token; payload.token = token;
@@ -91,70 +89,105 @@ const RegisterPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.register.title")}
title={t("pages.auth.register.title")} subtitle={t("pages.auth.register.subtitle")}
subtitle={t("pages.auth.register.subtitle")} >
> {!token && (
{!token && ( <Alert
<Alert severity="info"
severity="info" icon={false}
icon={false} sx={(theme) => ({
sx={(theme) => ({ fontSize: 13,
fontSize: 13, lineHeight: 1.55,
lineHeight: 1.55, color: theme.palette.text.secondary,
color: theme.palette.text.secondary, backgroundColor: theme.palette.action.hover,
backgroundColor: theme.palette.action.hover, border: `1px solid ${theme.palette.divider}`,
border: `1px solid ${theme.palette.divider}`, borderRadius: 1,
borderRadius: 1, "& .MuiAlert-message": { padding: 0 },
"& .MuiAlert-message": { padding: 0 }, })}
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<FormTextField
name="email"
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
{t("pages.auth.register.submit")} {t("pages.auth.register.setupNotice")}
</Button> </Alert>
</BaseAuthPage> )}
</FormProvider> <Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
); );
}; };
+78 -66
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm"; import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword"; import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const SetNewPasswordPage = () => { const SetNewPasswordPage = () => {
const theme = useTheme(); const theme = useTheme();
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
const { schema, defaults } = useSetNewPasswordForm(); const { schema, defaults } = useSetNewPasswordForm();
const form = useForm<SetNewPasswordFormData>({ const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, watch } = form;
const password = watch("password"); const password = watch("password");
const confirm = watch("confirm"); const confirm = watch("confirm");
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.setNewPassword.title")}
title={t("pages.auth.setNewPassword.title")} subtitle={t("pages.auth.setNewPassword.subtitle")}
subtitle={t("pages.auth.setNewPassword.subtitle")} >
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
> >
<FormTextField {t("common.buttons.resetPassword")}
name="password" </Button>
type="password" <TextLink
fieldLabel={t("pages.auth.common.form.option.password.label")} alignSelf="center"
placeholder={t("pages.auth.common.form.option.password.placeholder")} text={t("pages.auth.forgotPassword.links.login.text")}
/> linkText={t("pages.auth.forgotPassword.links.login.linkText")}
<FormTextField href="/login"
name="confirm" />
type="password" </BaseAuthPage>
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -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),
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors"; import Stack from "@mui/material/Stack";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController"; import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => { const InfrastructureMonitors = () => {
const c = useMonitorListController({ const { t } = useTranslation();
types: ["hardware"], const theme = useTheme();
checksLimit: 1, const isSmall = useMediaQuery(theme.breakpoints.down("md"));
refreshInterval: 5000, const isAdmin = useIsAdmin();
rowsPerPageTable: "infrastructure", const dispatch = useDispatch();
rowsPerPageDefault: 5,
}); // Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="infrastructure" headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure" page="infrastructure"
actionLink="/infrastructure/create" actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
> >
<InfraMonitorsTable <HeaderCreate
monitors={c.monitors || []} path="/infrastructure/create"
tags={c.tags || []} isLoading={isLoading}
refetch={c.refetch} isAdmin={isAdmin}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
/> />
</MonitorListPage> <HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
+9 -5
View File
@@ -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",
+258 -117
View File
@@ -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>
); );
}; };
+381 -192
View File
@@ -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>
); );
}; };
+2 -4
View File
@@ -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>
); );
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements"; import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi"; import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings"; import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors"; import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
const PageSpeedMonitorsPage = () => { const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const { const {
data: settingsData, data: settingsData,
isLoading: settingsIsLoading, isLoading: settingsIsLoading,
error: settingsError, error: settingsError,
} = useGet<AppSettingsResponse>("/settings"); } = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({ const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
types: ["pagespeed"], const isDialogOpen = Boolean(selectedMonitor);
checksLimit: 25, const [sortField, setSortField] = useState<string>("name");
refreshInterval: 30000, const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
rowsPerPageTable: "pagespeed", const [page, setPage] = useState(0);
rowsPerPageDefault: 10, const [rowsPerPage, setRowsPerPage] = useState(10);
initialSortField: "name", const [selectedTags, setSelectedTags] = useState<string[]>([]);
}); const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet; const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="pageSpeed" headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed" page="pageSpeed"
actionLink="/pagespeed/create" actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined} priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
> >
<PageSpeedMonitorsTable <HeaderCreate
monitors={c.monitors || []} path="/pagespeed/create"
tags={c.tags || []} isLoading={isLoading}
refetch={c.refetch} isAdmin={isAdmin}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
/> />
</MonitorListPage> <HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
@@ -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} />}
</>
);
};
-213
View File
@@ -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;
-72
View File
@@ -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;
+2 -1
View File
@@ -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(),
}); });
File diff suppressed because it is too large. Load diff
+493 -289
View File
@@ -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>
); );
}; };
+3 -27
View File
@@ -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,
Loaded 100 of 331 files, more files were not shown because too many files have changed in this diff. Show more