Compare commits
266
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc4a30c607 | ||
|
|
f41e0f0619 | ||
|
|
3f10b6e567 | ||
|
|
9ad20daa51 | ||
|
|
6a9068a151 | ||
|
|
b31ffa5318 | ||
|
|
102a9f1484 | ||
|
|
581ec9382b | ||
|
|
cebe9350f7 | ||
|
|
406518e0a5 | ||
|
|
c458d5fb65 | ||
|
|
ba0d685c6d | ||
|
|
912f1f960d | ||
|
|
1de0edf7e3 | ||
|
|
8c9cb4be77 | ||
|
|
727b904ca9 | ||
|
|
5c0279a6a7 | ||
|
|
9d72d20dd2 | ||
|
|
62d0630702 | ||
|
|
2260b4a4d2 | ||
|
|
cfb50afe42 | ||
|
|
b14b2142e3 | ||
|
|
c64c4cc5ff | ||
|
|
7071de294f | ||
|
|
5eb16fdbdb | ||
|
|
131fd1a6b0 | ||
|
|
f9bc02ed1c | ||
|
|
1035e397e0 | ||
|
|
9c788561a7 | ||
|
|
449e96d842 | ||
|
|
c7626c669a | ||
|
|
ede5bc4ecd | ||
|
|
8bb60bfd03 | ||
|
|
f009aa17d9 | ||
|
|
ef7ac8bef8 | ||
|
|
b75f895a1f | ||
|
|
53f5b61e8e | ||
|
|
64a2c69540 | ||
|
|
c4ec14db5d | ||
|
|
4560820649 | ||
|
|
3051bc7edc | ||
|
|
22232489a8 | ||
|
|
04277a6eb4 | ||
|
|
a8dc867e78 | ||
|
|
533783ba3e | ||
|
|
fd7b3e5f20 | ||
|
|
ae4b851620 | ||
|
|
e83f97a05a | ||
|
|
070682af74 | ||
|
|
8609e4bca0 | ||
|
|
2661d7a384 | ||
|
|
5b9d554f46 | ||
|
|
7fb8bab4be | ||
|
|
75d29124b6 | ||
|
|
1f0ea6cedc | ||
|
|
07a38b9c2c | ||
|
|
67806c94b1 | ||
|
|
26c0628f34 | ||
|
|
4bbc84ac48 | ||
|
|
2eeb088b61 | ||
|
|
7862b3c6f9 | ||
|
|
02b0daa37d | ||
|
|
1492569b73 | ||
|
|
a545b81569 | ||
|
|
ce9dce48b3 | ||
|
|
7fcdcc0568 | ||
|
|
9eea53aa5f | ||
|
|
bd7b6038f5 | ||
|
|
e2362ca809 | ||
|
|
9a541dac21 | ||
|
|
71fed33ecd | ||
|
|
074b94b061 | ||
|
|
c1af8995e4 | ||
|
|
431cada08e | ||
|
|
e012910332 | ||
|
|
7dbc7b3064 | ||
|
|
1b97539eae | ||
|
|
6e0afe223e | ||
|
|
37e1005479 | ||
|
|
795e1caca4 | ||
|
|
a5bb186814 | ||
|
|
bee3d4221e | ||
|
|
f7e1793bac | ||
|
|
9a8203b0d8 | ||
|
|
24d4595bb1 | ||
|
|
6587ddf389 | ||
|
|
9b939f2d94 | ||
|
|
530eeb0110 | ||
|
|
da0d2a47b4 | ||
|
|
700baf2061 | ||
|
|
de8eb53dd4 | ||
|
|
dbe0e2f4e3 | ||
|
|
3ddc6ef1cb | ||
|
|
ac94ae0fdd | ||
|
|
acbf26b03d | ||
|
|
f931954997 | ||
|
|
2cfc2915ce | ||
|
|
d965a82cc9 | ||
|
|
62fb17f64c | ||
|
|
8582958ad5 | ||
|
|
1372e66749 | ||
|
|
bdad949170 | ||
|
|
e69a2f05af | ||
|
|
9c796edbde | ||
|
|
84a1b994a1 | ||
|
|
db4689207a | ||
|
|
af465766c8 | ||
|
|
6ea4ca1aa9 | ||
|
|
3e06cd39b1 | ||
|
|
efcc7829d4 | ||
|
|
77974682fb | ||
|
|
a1c54997bb | ||
|
|
5657d39589 | ||
|
|
cdd4953e03 | ||
|
|
786c803847 | ||
|
|
c13f26a4c3 | ||
|
|
56153b38a5 | ||
|
|
f85ed902e4 | ||
|
|
8ccc7cbe9a | ||
|
|
d347e29a28 | ||
|
|
f10fb452ba | ||
|
|
fc9d2fe69d | ||
|
|
ca896fa22f | ||
|
|
a7ee90e1ee | ||
|
|
06190a885a | ||
|
|
5f07860217 | ||
|
|
073635d786 | ||
|
|
af1d964e44 | ||
|
|
3b6b7a1e5d | ||
|
|
bc44a3baeb | ||
|
|
4e4bc5f319 | ||
|
|
2fec09df62 | ||
|
|
13e49c518d | ||
|
|
a1f9c0304f | ||
|
|
d158bcdce1 | ||
|
|
c45a21b4c8 | ||
|
|
a0738dff63 | ||
|
|
3cc4db9af5 | ||
|
|
94ecbfe365 | ||
|
|
5343241036 | ||
|
|
261fdd08f1 | ||
|
|
5470f44ebb | ||
|
|
b24e42e516 | ||
|
|
09d20c1bc7 | ||
|
|
d59bdf6c0c | ||
|
|
9d0f0116f3 | ||
|
|
3e56dc63a9 | ||
|
|
30881a0af1 | ||
|
|
ebbf1a8b1e | ||
|
|
9a6602b9e1 | ||
|
|
46211ac61d | ||
|
|
791a0c8483 | ||
|
|
e964234f54 | ||
|
|
e60bfd18ff | ||
|
|
560d64a482 | ||
|
|
c8826d90d5 | ||
|
|
cacedfd7d8 | ||
|
|
4ec03dfd04 | ||
|
|
78d3b23e93 | ||
|
|
1a1dc7d807 | ||
|
|
03174d98dd | ||
|
|
686ded2137 | ||
|
|
9c8bf4bb05 | ||
|
|
8f6c909acd | ||
|
|
1ca38a31c9 | ||
|
|
9ffcbd1242 | ||
|
|
840e12eb9a | ||
|
|
96b2266a9d | ||
|
|
80073af80a | ||
|
|
0c9334071f | ||
|
|
e444d5fbe6 | ||
|
|
e9dca23664 | ||
|
|
7fb0e9dc70 | ||
|
|
de148f9773 | ||
|
|
021f99e0be | ||
|
|
54ffd74048 | ||
|
|
4bc71a3d18 | ||
|
|
d03765c5fd | ||
|
|
f30085bae3 | ||
|
|
adc6af83e3 | ||
|
|
c24440f8c9 | ||
|
|
d8ce2dfdc8 | ||
|
|
28f8542bce | ||
|
|
2e258fa39a | ||
|
|
7dedcca41d | ||
|
|
97d6222297 | ||
|
|
14ddfc97b0 | ||
|
|
b23dbfeae0 | ||
|
|
68679bc0bf | ||
|
|
959a434d21 | ||
|
|
4298397e3e | ||
|
|
fc3fab4905 | ||
|
|
0b54b5c6cd | ||
|
|
358b1deec7 | ||
|
|
9839d48ca0 | ||
|
|
83c2df4390 | ||
|
|
31f6727519 | ||
|
|
96f8bf4a66 | ||
|
|
06bf5e45f5 | ||
|
|
d0fc402683 | ||
|
|
b922654cfc | ||
|
|
d7dce5d7a3 | ||
|
|
5d2aa28570 | ||
|
|
af3c72b055 | ||
|
|
b3e98ac95d | ||
|
|
3f62c8ec6a | ||
|
|
b2dca7b62f | ||
|
|
f731648007 | ||
|
|
a1199f957d | ||
|
|
ea8ad88ae9 | ||
|
|
6e271f834c | ||
|
|
0c21dcdd2a | ||
|
|
9bb95e1f0c | ||
|
|
ccfda938d5 | ||
|
|
cc137e1d06 | ||
|
|
41859f241a | ||
|
|
252067142e | ||
|
|
1152a70afb | ||
|
|
ffd7ea7517 | ||
|
|
a56bff2305 | ||
|
|
db05db0c0c | ||
|
|
cd520dacc5 | ||
|
|
637bb5cca9 | ||
|
|
ede93add64 | ||
|
|
4443a53a23 | ||
|
|
1b2d2887af | ||
|
|
54690fc73c | ||
|
|
6e3252aec8 | ||
|
|
edd9335e96 | ||
|
|
cf13bc329e | ||
|
|
80097afb71 | ||
|
|
5435167e73 | ||
|
|
059dc2737b | ||
|
|
158f2fffbe | ||
|
|
68c1f2e281 | ||
|
|
872e5f32cb | ||
|
|
d368ec5bd7 | ||
|
|
825bcc5fdb | ||
|
|
69cdf948da | ||
|
|
d337bb235c | ||
|
|
0c0f43174d | ||
|
|
51149d1dd4 | ||
|
|
448fcc4628 | ||
|
|
ee5462d039 | ||
|
|
7ee6d342e9 | ||
|
|
49d3737a93 | ||
|
|
4402e4fbfa | ||
|
|
721f77ed26 | ||
|
|
34f29e5ae6 | ||
|
|
c2170267e8 | ||
|
|
321ca59380 | ||
|
|
e3ecfe6ad8 | ||
|
|
f4664eec16 | ||
|
|
56f601e34b | ||
|
|
1244ff6d93 | ||
|
|
c0ea804c98 | ||
|
|
c21cf29db6 | ||
|
|
3d0da70ce6 | ||
|
|
feb061240b | ||
|
|
a924227805 | ||
|
|
2144d3f8c0 | ||
|
|
fcd6786149 | ||
|
|
ea968ebf3c | ||
|
|
8a7b852974 | ||
|
|
c8d3e3b70c | ||
|
|
7a90a6f9f4 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
@@ -1,138 +0,0 @@
|
||||
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>
|
||||
@@ -1,113 +0,0 @@
|
||||
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."
|
||||
@@ -109,7 +109,7 @@ client/src/
|
||||
- **Data Fetching**: SWR + Axios
|
||||
- **Database**: MongoDB with Mongoose ODM
|
||||
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
||||
- **i18n**: i18next + react-i18next (translations via PoEditor)
|
||||
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
|
||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
|
||||
|
||||
[](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="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
|
||||
</p>
|
||||
<br>
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
||||
|
||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||
|
||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
The image is configured entirely through environment variables on the server container:
|
||||
@@ -100,7 +104,15 @@ The image is configured entirely through environment variables on the server con
|
||||
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
|
||||
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
|
||||
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
|
||||
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
|
||||
| `PORT` | No | Port the API and web client are served on (default `52345`) |
|
||||
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
|
||||
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
|
||||
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
|
||||
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
|
||||
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
|
||||
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
|
||||
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
|
||||
|
||||
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
|
||||
|
||||
@@ -114,12 +126,119 @@ The web client needs no configuration by default: it calls the API on the same o
|
||||
|
||||
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
|
||||
|
||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||
|
||||
### Using a Custom CA
|
||||
|
||||
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
||||
|
||||
### Docker monitors
|
||||
|
||||
A Docker monitor connects to a Docker daemon and reports on every container it runs. The daemon's ping response decides whether the monitor is up or down and its latency is the response time. Each check also records every container's state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every check; logs are kept for 7 days.
|
||||
|
||||
The **Docker host** field accepts two forms:
|
||||
|
||||
| Host | Example | Notes |
|
||||
|---|---|---|
|
||||
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
|
||||
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
|
||||
|
||||
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
checkmate:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
group_add:
|
||||
- "989" # the gid printed by stat
|
||||
```
|
||||
|
||||
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
|
||||
|
||||
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
|
||||
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
|
||||
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
|
||||
|
||||
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
|
||||
|
||||
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
|
||||
|
||||
### Securing the Docker daemon with TLS
|
||||
|
||||
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
|
||||
|
||||
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
|
||||
|
||||
```bash
|
||||
openssl genrsa -aes256 -out ca-key.pem 4096
|
||||
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
|
||||
```
|
||||
|
||||
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
|
||||
|
||||
```bash
|
||||
openssl genrsa -out server-key.pem 4096
|
||||
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
|
||||
cat > server-ext.cnf <<EOF
|
||||
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
|
||||
extendedKeyUsage = serverAuth
|
||||
EOF
|
||||
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||
-out server-cert.pem -extfile server-ext.cnf
|
||||
```
|
||||
|
||||
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
|
||||
|
||||
```bash
|
||||
openssl genrsa -out key.pem 4096
|
||||
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
|
||||
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
|
||||
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||
-out cert.pem -extfile client-ext.cnf
|
||||
rm client.csr server.csr server-ext.cnf client-ext.cnf
|
||||
chmod 0400 ca-key.pem key.pem server-key.pem
|
||||
chmod 0444 ca.pem server-cert.pem cert.pem
|
||||
```
|
||||
|
||||
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
|
||||
"tls": true,
|
||||
"tlsverify": true,
|
||||
"tlscacert": "/etc/docker/certs/ca.pem",
|
||||
"tlscert": "/etc/docker/certs/server-cert.pem",
|
||||
"tlskey": "/etc/docker/certs/server-key.pem"
|
||||
}
|
||||
```
|
||||
|
||||
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit docker.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/dockerd
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload && sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Open port `2376` on the host firewall only to the machine running Checkmate.
|
||||
|
||||
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
|
||||
|
||||
```bash
|
||||
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
|
||||
-H=docker.example.com:2376 version
|
||||
```
|
||||
|
||||
For more documentation, see the [docs directory](./docs/).
|
||||
|
||||
<a id="performance"></a>
|
||||
@@ -129,7 +248,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
|
||||
|
||||

|
||||
|
||||
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
|
||||
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
|
||||
|
||||

|
||||
|
||||
@@ -147,16 +266,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
## Features
|
||||
|
||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
|
||||
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
|
||||
- Page speed monitoring
|
||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||
- Selective disk monitoring with mountpoint selection
|
||||
- Incidents at a glance
|
||||
- Status pages with 4 beautiful themes
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||
- Status pages with 5 beautiful themes
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
|
||||
- Scheduled maintenance
|
||||
- JSON query monitoring
|
||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||
|
||||
|
||||
## Monitor Lifecycle
|
||||
@@ -207,9 +326,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
<a id="contributing"></a>
|
||||
## Contributing
|
||||
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) 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), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) 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 7000+ stars and attracted 90+ 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 almost 11K stars and attracted 150+ 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!
|
||||
|
||||
|
||||
Generated
+34
-4
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.10.0",
|
||||
"version": "3.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.10.0",
|
||||
"version": "3.12.0",
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/lab": "7.0.0",
|
||||
@@ -92,6 +93,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -407,6 +409,7 @@
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -444,6 +447,7 @@
|
||||
"node_modules/@emotion/styled": {
|
||||
"version": "11.14.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -950,6 +954,15 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/geist": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "9.3.0",
|
||||
"license": "BSD-3-Clause"
|
||||
@@ -1223,6 +1236,7 @@
|
||||
"node_modules/@mui/material": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.7",
|
||||
@@ -1327,6 +1341,7 @@
|
||||
"node_modules/@mui/system": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/private-theming": "^7.3.7",
|
||||
@@ -2371,6 +2386,7 @@
|
||||
"version": "24.5.2",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.12.0"
|
||||
}
|
||||
@@ -2386,6 +2402,7 @@
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2476,6 +2493,7 @@
|
||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.18.0",
|
||||
"@typescript-eslint/types": "7.18.0",
|
||||
@@ -2756,6 +2774,7 @@
|
||||
"version": "8.15.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3112,6 +3131,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
@@ -3555,7 +3575,8 @@
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3954,6 +3975,7 @@
|
||||
"version": "8.57.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4766,6 +4788,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -5577,6 +5600,7 @@
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
@@ -6358,6 +6382,7 @@
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6369,6 +6394,7 @@
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.71.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -6572,7 +6598,8 @@
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-persist": {
|
||||
"version": "6.0.0",
|
||||
@@ -6722,6 +6749,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -7563,6 +7591,7 @@
|
||||
"version": "5.9.3",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7700,6 +7729,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
+5
-2
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.10.0",
|
||||
"version": "3.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build-dev": "tsc -b && vite build --mode development",
|
||||
@@ -11,7 +12,8 @@
|
||||
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"format-check": "prettier --check ."
|
||||
"format-check": "prettier --check .",
|
||||
"compile": "tsc -b"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
@@ -20,6 +22,7 @@
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@hello-pangea/dnd": "^18.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@mui/lab": "7.0.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: number | string;
|
||||
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
|
||||
};
|
||||
|
||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||
const theme = useTheme();
|
||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton onClick={handleClick}>
|
||||
<IconButton
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.controlHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Settings
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaQuery } from "@mui/material";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
interface MonitorTimeFrameHeaderProps {
|
||||
isLoading?: boolean;
|
||||
hasDateRange?: boolean;
|
||||
dateRange: string;
|
||||
setDateRange: (dateRange: string) => void;
|
||||
dateRange: DateRange;
|
||||
setDateRange: (dateRange: DateRange) => void;
|
||||
}
|
||||
|
||||
export const HeaderTimeRange = ({
|
||||
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const handleChange = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null
|
||||
newValue: DateRange | null
|
||||
) => {
|
||||
if (newValue !== null) {
|
||||
setDateRange(newValue);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Box, { type BoxProps } from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
@@ -16,4 +18,6 @@ export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
BaseBox.displayName = "BaseBox";
|
||||
@@ -10,8 +10,10 @@ import { useTheme } from "@mui/material/styles";
|
||||
type BaseChartProps = React.PropsWithChildren<{
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
rightTitle?: string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
flexBasis?: number | string;
|
||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
@@ -19,8 +21,10 @@ type BaseChartProps = React.PropsWithChildren<{
|
||||
export const BaseChart = ({
|
||||
children,
|
||||
title,
|
||||
rightTitle,
|
||||
width = "100%",
|
||||
maxWidth = "100%",
|
||||
flexBasis = "0%",
|
||||
padding,
|
||||
onClick,
|
||||
}: BaseChartProps) => {
|
||||
@@ -32,6 +36,7 @@ export const BaseChart = ({
|
||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexBasis,
|
||||
width: width,
|
||||
maxWidth: maxWidth,
|
||||
...(onClick && {
|
||||
@@ -44,13 +49,27 @@ export const BaseChart = ({
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
flex={1}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{rightTitle && (
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color="text.secondary"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{rightTitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Box flex={1}>{children}</Box>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
|
||||
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
|
||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||
|
||||
const secondSegment = segments[1];
|
||||
const thirdSegment = segments[2];
|
||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||
const hasSubPage = isActionPage || isDetailsPage;
|
||||
// page/section/:id routes such as docker/host/:monitorId
|
||||
const isSectionDetailsPage = Boolean(
|
||||
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
|
||||
);
|
||||
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
|
||||
|
||||
const getSubPageLabel = (): string => {
|
||||
if (isActionPage) {
|
||||
if (isActionPage || isSectionDetailsPage) {
|
||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||
}
|
||||
return t("common.breadcrumbs.details");
|
||||
|
||||
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
||||
() => ({
|
||||
circumference: 2 * Math.PI * radius,
|
||||
totalSize: radius * 2 + strokeWidth * 2,
|
||||
strokeLength: (progress / 100) * (2 * Math.PI * radius),
|
||||
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
|
||||
}),
|
||||
[radius, strokeWidth, progress]
|
||||
[radius, strokeWidth, progressWithinRange]
|
||||
);
|
||||
|
||||
const [offset, setOffset] = useState(circumference);
|
||||
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [progress, circumference, strokeLength]);
|
||||
}, [circumference, strokeLength]);
|
||||
|
||||
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
||||
|
||||
@@ -123,6 +123,7 @@ export const DetailGauge = ({
|
||||
lowerLabel,
|
||||
lowerValue,
|
||||
maxWidth = 225,
|
||||
flexBasis = "0%",
|
||||
}: {
|
||||
title: string;
|
||||
progress: number;
|
||||
@@ -131,6 +132,7 @@ export const DetailGauge = ({
|
||||
lowerLabel?: string;
|
||||
lowerValue?: string | number;
|
||||
maxWidth?: number;
|
||||
flexBasis?: number | string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
@@ -138,6 +140,7 @@ export const DetailGauge = ({
|
||||
icon={null}
|
||||
title={title}
|
||||
maxWidth={maxWidth}
|
||||
flexBasis={flexBasis}
|
||||
>
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface IconProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
|
||||
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
size={size}
|
||||
|
||||
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: theme.palette.error.contrastText,
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
color: "#FFFFFF",
|
||||
px: theme.spacing(8),
|
||||
py: theme.spacing(4),
|
||||
transition: "top 1s ease-in-out",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -109,7 +110,7 @@ export const PageHeader = ({
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
maxWidth: 640,
|
||||
maxWidth: "108ch",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
@@ -142,7 +143,7 @@ export const PageHeader = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 32,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
color: theme.palette.text.secondary,
|
||||
borderRight:
|
||||
idx < linkItems.length - 1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
width?: number | string | { xs?: number | string; md?: number | string };
|
||||
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
|
||||
|
||||
const blockStyle = {
|
||||
width: 55,
|
||||
height: 34,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
import { darken, lighten } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
||||
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
|
||||
type GradientBox = React.PropsWithChildren<{
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
interactive?: boolean;
|
||||
}>;
|
||||
|
||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
||||
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
||||
const paperEnd = paper;
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
||||
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
return (
|
||||
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||
background: bg,
|
||||
...(interactive && {
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
|
||||
},
|
||||
}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
@@ -62,10 +69,8 @@ export const StatBox = ({
|
||||
return (
|
||||
<GradientBox
|
||||
palette={palette}
|
||||
sx={{
|
||||
...(sx as object),
|
||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
||||
}}
|
||||
interactive={Boolean(onClick)}
|
||||
sx={{ ...(sx as object) }}
|
||||
>
|
||||
<Stack onClick={onClick}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
type StatusBoxProps = React.PropsWithChildren<{
|
||||
children: React.ReactNode;
|
||||
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
backgroundImage: `
|
||||
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
|
||||
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "24px 24px",
|
||||
maskImage:
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Stack from "@mui/material/Stack";
|
||||
|
||||
import type { MonitorStatus } from "@/Types/Monitor";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
||||
import {
|
||||
getDockerStatePalette,
|
||||
getStatusPalette,
|
||||
getValuePalette,
|
||||
} from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainerState } from "@/Types/Check";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||
export type ValueType = (typeof ValueTypes)[number];
|
||||
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const DockerStateLabel = ({
|
||||
state,
|
||||
name,
|
||||
image,
|
||||
}: {
|
||||
state: DockerContainerState;
|
||||
name: string;
|
||||
image: string;
|
||||
}) => {
|
||||
const palette = getDockerStatePalette(state);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={SPACING.LG}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<Box
|
||||
width={7}
|
||||
height={7}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
<Stack>
|
||||
<Typography>{name}</Typography>
|
||||
<Typography variant="body2">{image}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -52,6 +52,7 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
||||
emptyViewText?: string;
|
||||
emptyViewPositive?: boolean;
|
||||
getRowSx?: (row: T) => SxProps<Theme>;
|
||||
isRowSelected?: (row: T) => boolean;
|
||||
};
|
||||
|
||||
export function DataTable<
|
||||
@@ -70,6 +71,7 @@ export function DataTable<
|
||||
emptyViewText,
|
||||
emptyViewPositive,
|
||||
getRowSx,
|
||||
isRowSelected,
|
||||
}: DataTableProps<T>) {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||
@@ -80,6 +82,22 @@ export function DataTable<
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const hasTextSelectionWithin = (element: Element) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
selection.anchorNode !== null &&
|
||||
element.contains(
|
||||
selection.anchorNode.nodeType === Node.TEXT_NODE
|
||||
? selection.anchorNode.parentNode
|
||||
: selection.anchorNode
|
||||
)
|
||||
);
|
||||
};
|
||||
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||
|
||||
if (data.length === 0 || headers.length === 0) {
|
||||
return (
|
||||
<EmptyView
|
||||
@@ -99,7 +117,10 @@ export function DataTable<
|
||||
keys.push(key);
|
||||
return (
|
||||
<Stack
|
||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
||||
onClick={(e) => {
|
||||
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||
if (onRowClick) onRowClick(row);
|
||||
}}
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
sx={{
|
||||
borderStyle: "solid",
|
||||
@@ -108,6 +129,11 @@ export function DataTable<
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(LAYOUT.XS),
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
...(onRowClick && {
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.rowHover,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
key={key}
|
||||
>
|
||||
@@ -191,7 +217,7 @@ export function DataTable<
|
||||
"& :is(th)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.08em",
|
||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||
@@ -227,15 +253,29 @@ export function DataTable<
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
const isExpanded = expanded === key;
|
||||
const selected = isRowSelected?.(row);
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<TableRow
|
||||
sx={{
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
cursor: isInteractive ? "pointer" : "default",
|
||||
...(isInteractive && {
|
||||
"&:hover .MuiTableCell-root": {
|
||||
backgroundColor: selected
|
||||
? theme.palette.action.selectedHover
|
||||
: theme.palette.action.rowHover,
|
||||
},
|
||||
}),
|
||||
...(selected && {
|
||||
"& .MuiTableCell-root": {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
}),
|
||||
...(getRowSx?.(row) as object),
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||
if (expandableRows) handleExpand(row);
|
||||
else if (onRowClick) onRowClick(row);
|
||||
}}
|
||||
@@ -292,6 +332,12 @@ interface TablePaginationActionsProps {
|
||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||
}
|
||||
|
||||
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.controlHover,
|
||||
},
|
||||
});
|
||||
|
||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
const theme = useTheme();
|
||||
const { count, page, rowsPerPage, onPageChange } = props;
|
||||
@@ -321,6 +367,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
@@ -338,6 +385,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
@@ -355,6 +403,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="next page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
@@ -372,6 +421,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
onClick={handleLastPageButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="last page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsLeft
|
||||
@@ -419,6 +469,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
@@ -436,6 +487,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
@@ -453,6 +505,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
@@ -470,6 +523,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
||||
<IconButton
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
sx={controlHoverSx(theme)}
|
||||
>
|
||||
<Ellipsis
|
||||
size={20}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import type { TabsProps } from "@mui/material/Tabs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
interface CustomTabsProps extends TabsProps {}
|
||||
|
||||
export const Tabs = (props: CustomTabsProps) => {
|
||||
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
||||
return (
|
||||
<MuiTabs
|
||||
sx={{
|
||||
minHeight: 34,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
"& .MuiTabs-indicator": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
@@ -38,9 +40,9 @@ export const Tab = (props: CustomTabProps) => {
|
||||
iconPosition="start"
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
fontSize: 14,
|
||||
fontSize: typographyLevels.m,
|
||||
fontWeight: 500,
|
||||
minHeight: 34,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
padding: theme.spacing(1, 0),
|
||||
paddingBottom: 0,
|
||||
minWidth: "auto",
|
||||
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-selected": {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 600,
|
||||
},
|
||||
"&:hover": {
|
||||
color: theme.palette.text.secondary,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme, darken } from "@mui/material/styles";
|
||||
import { HOVER } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||
|
||||
const PALETTE_COLORS = [
|
||||
"primary",
|
||||
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
{...props}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 400,
|
||||
borderRadius: 2,
|
||||
whiteSpace: "nowrap",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface ColorPickerProps extends MuiColorInputProps {
|
||||
fieldLabel?: string;
|
||||
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FieldLabel } from "./FieldLabel";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
p: 0,
|
||||
pl: theme.spacing(3),
|
||||
pr: theme.spacing(5),
|
||||
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
|
||||
},
|
||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||
{
|
||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||
borderColor: error
|
||||
? theme.palette.error.main
|
||||
: theme.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,15 +6,17 @@ import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const SelectInputInner = <T,>(
|
||||
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
|
||||
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
@@ -27,7 +29,7 @@ const SelectInputInner = <T,>(
|
||||
: selected === undefined || selected === null || selected === "";
|
||||
|
||||
if (isEmpty && placeholder) {
|
||||
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
||||
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
@@ -70,7 +72,7 @@ const SelectInputInner = <T,>(
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
height: "34px",
|
||||
height: `${INPUT_BASE_HEIGHT}px`,
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -82,21 +84,27 @@ const SelectInputInner = <T,>(
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
const helper = helperText ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
|
||||
>
|
||||
{helperText}
|
||||
</Typography>
|
||||
) : null;
|
||||
|
||||
if (fieldLabel || helper) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
||||
{select}
|
||||
{helper}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -22,16 +22,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: props.multiline ? "auto" : 34,
|
||||
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
@@ -39,7 +33,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
},
|
||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
||||
{
|
||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
|
||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
|
||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||
caretColor: theme.palette.text.primary,
|
||||
transition: "background-color 5000s ease-in-out 0s",
|
||||
|
||||
@@ -7,6 +7,7 @@ import Typography from "@mui/material/Typography";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -33,7 +34,7 @@ export const TimePickerComponent = ({
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& input": {
|
||||
minHeight: 34,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
p: 0,
|
||||
px: theme.spacing(5),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
sx={{
|
||||
px: 8,
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
fontWeight: 400,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: selectedBg,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 500,
|
||||
"&:hover": {
|
||||
backgroundColor: selectedHoverBg,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} 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;
|
||||
@@ -17,10 +18,12 @@ export interface FormRadioOption {
|
||||
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();
|
||||
@@ -30,6 +33,7 @@ export const FormRadioGroup = <T extends FieldValues>({
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormControl error={!!fieldState.error}>
|
||||
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
|
||||
<RadioGroup
|
||||
{...field}
|
||||
sx={{ gap: theme.spacing(LAYOUT.MD) }}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const FormSelectField = <T extends FieldValues>({
|
||||
}}
|
||||
error={!!fieldState.error}
|
||||
{...rest}
|
||||
helperText={fieldState.error?.message ?? rest.helperText}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem
|
||||
|
||||
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
||||
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
|
||||
const typeDisplayNames: Record<string, string> = {
|
||||
http: "HTTP",
|
||||
ping: "Ping",
|
||||
port: "Port",
|
||||
docker: "Docker",
|
||||
game: "Game",
|
||||
grpc: "gRPC",
|
||||
websocket: "WebSocket",
|
||||
|
||||
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
loading={isPosting}
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
onClick={async () => {
|
||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||
}}
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.testNotifications")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
loading={isPosting}
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
onClick={async () => {
|
||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||
}}
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.testNotifications")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
|
||||
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
|
||||
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { MonitorListController } from "@/Hooks/useMonitorListController";
|
||||
import { useMediaQuery, useTheme } from "@mui/material";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { TextField, Button, Dialog } from "@/Components/inputs";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
|
||||
interface MonitorListPageProps {
|
||||
headerKey: string;
|
||||
page: string;
|
||||
actionLink: string;
|
||||
controller: MonitorListController;
|
||||
bulkActions?: boolean;
|
||||
showTypeFilter?: boolean;
|
||||
summaryProps?: { showBreached?: boolean };
|
||||
priorityFallback?: ReactNode;
|
||||
extraLoading?: boolean;
|
||||
extraError?: unknown;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
|
||||
|
||||
export const MonitorListPage = ({
|
||||
headerKey,
|
||||
page,
|
||||
actionLink,
|
||||
controller,
|
||||
bulkActions,
|
||||
showTypeFilter,
|
||||
summaryProps,
|
||||
priorityFallback,
|
||||
extraLoading,
|
||||
extraError,
|
||||
children,
|
||||
}: MonitorListPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isAdmin = useIsAdmin();
|
||||
const isLoading = controller.isLoading || Boolean(extraLoading);
|
||||
|
||||
return (
|
||||
<MonitorBasePageWithStates
|
||||
headerKey={headerKey}
|
||||
loading={isLoading}
|
||||
error={controller.error || extraError}
|
||||
totalCount={controller.effectiveTotalCount}
|
||||
page={page}
|
||||
actionLink={actionLink}
|
||||
priorityFallback={priorityFallback}
|
||||
>
|
||||
<HeaderCreate
|
||||
path={actionLink}
|
||||
isLoading={isLoading}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<HeaderMonitorsSummary
|
||||
summary={controller.summary}
|
||||
{...summaryProps}
|
||||
/>
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<ControlsFilter
|
||||
{...(showTypeFilter === false
|
||||
? { showTypes: false }
|
||||
: {
|
||||
selectedTypes: controller.selectedTypes,
|
||||
setSelectedTypes: controller.setSelectedTypes,
|
||||
})}
|
||||
selectedStatus={controller.selectedStatus}
|
||||
setSelectedStatus={controller.setSelectedStatus}
|
||||
selectedState={controller.selectedState}
|
||||
setSelectedState={controller.setSelectedState}
|
||||
tagOptions={controller.tags ?? []}
|
||||
selectedTags={controller.selectedTags}
|
||||
setSelectedTags={controller.setSelectedTags}
|
||||
onClearFilters={controller.handleClearFilters}
|
||||
/>
|
||||
<TextField
|
||||
placeholder={t("pages.uptime.filters.search.placeholder")}
|
||||
value={controller.search}
|
||||
onChange={(event) => {
|
||||
controller.setSearch(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{controller.selectedTags.length > 0 && (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
alignItems={isSmall ? "flex-start" : "center"}
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(SPACING.XL)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.uptime.filters.activeTags")}
|
||||
</Typography>
|
||||
{controller.selectedTags.map((tagId) => {
|
||||
const tag = controller.tags?.find((t) => t.id === tagId);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<ColoredLabel
|
||||
key={tag.id}
|
||||
text={tag.name}
|
||||
color={tag.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
{bulkActions && !isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={controller.selectedRows.length}
|
||||
onCancel={controller.handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={controller.handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={controller.handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
{children}
|
||||
<Dialog
|
||||
open={controller.isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={controller.handleConfirmDelete}
|
||||
onCancel={controller.handleCancelDelete}
|
||||
loading={controller.isDeleting}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
);
|
||||
};
|
||||
@@ -1,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",
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
export const computeYAxisCap = (values: number[]): number | undefined => {
|
||||
const sorted = values.slice().sort((a, b) => a - b);
|
||||
if (sorted.length < 2) return undefined;
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
||||
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
||||
};
|
||||
|
||||
export const PHASE_KEYS = [
|
||||
"avgDns",
|
||||
"avgTcp",
|
||||
"avgTls",
|
||||
"avgRequest",
|
||||
"avgFirstByte",
|
||||
"avgDownload",
|
||||
] as const;
|
||||
|
||||
export const PHASE_COLOR_KEYS = {
|
||||
avgDns: "dns",
|
||||
avgTcp: "tcp",
|
||||
avgTls: "tls",
|
||||
avgRequest: "request",
|
||||
avgFirstByte: "firstByte",
|
||||
avgDownload: "download",
|
||||
} as const;
|
||||
|
||||
// i18n keys, resolved with t() at render
|
||||
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
||||
avgDns: "common.charts.phases.dns",
|
||||
avgTcp: "common.charts.phases.tcp",
|
||||
avgTls: "common.charts.phases.tls",
|
||||
avgRequest: "common.charts.phases.request",
|
||||
avgFirstByte: "common.charts.phases.firstByte",
|
||||
avgDownload: "common.charts.phases.download",
|
||||
};
|
||||
|
||||
export const AREA_COLORS = [
|
||||
// Blues
|
||||
"#3182bd", // Deep blue
|
||||
"#6baed6", // Medium blue
|
||||
"#9ecae1", // Light blue
|
||||
|
||||
// Greens
|
||||
"#74c476", // Soft green
|
||||
"#a1d99b", // Light green
|
||||
"#c7e9c0", // Pale green
|
||||
|
||||
// Oranges
|
||||
"#fdae6b", // Warm orange
|
||||
"#fdd0a2", // Light orange
|
||||
"#feedde", // Pale orange
|
||||
|
||||
// Purples
|
||||
"#9467bd", // Lavender
|
||||
"#a55194", // Deep magenta
|
||||
"#c994c7", // Soft magenta
|
||||
|
||||
// Reds
|
||||
"#ff9896", // Soft red
|
||||
"#de2d26", // Deep red
|
||||
"#fc9272", // Medium red
|
||||
|
||||
// Cyans/Teals
|
||||
"#17becf", // Cyan
|
||||
"#7fcdbb", // Teal
|
||||
"#a1dab4", // Light teal
|
||||
|
||||
// Yellows
|
||||
"#fec44f", // Mustard
|
||||
"#fee391", // Light yellow
|
||||
"#ffffd4", // Pale yellow
|
||||
|
||||
// Additional colors
|
||||
"#e377c2", // Soft pink
|
||||
"#bcbd22", // Olive
|
||||
"#2ca02c", // Vibrant green
|
||||
];
|
||||
|
||||
export const createGradient = ({
|
||||
id,
|
||||
startColor,
|
||||
endColor,
|
||||
startOpacity = 0.8,
|
||||
endOpacity = 0,
|
||||
direction = "vertical",
|
||||
}: {
|
||||
id: string;
|
||||
startColor: string;
|
||||
endColor: string;
|
||||
startOpacity?: number;
|
||||
endOpacity?: number;
|
||||
direction?: "vertical" | "horizontal";
|
||||
}) => (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1={direction === "vertical" ? "0" : "0"}
|
||||
y1={direction === "vertical" ? "0" : "0"}
|
||||
x2={direction === "vertical" ? "0" : "1"}
|
||||
y2={direction === "vertical" ? "1" : "0"}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={startColor}
|
||||
stopOpacity={startOpacity}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={endColor}
|
||||
stopOpacity={endOpacity}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||
import type { TooltipProps } from "recharts";
|
||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Fragment, useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
// Utils
|
||||
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||
dataKey: string;
|
||||
range: string;
|
||||
theme: Theme;
|
||||
uiTimezone: string;
|
||||
valueFormatter: (value: number) => string;
|
||||
labelKey: string;
|
||||
};
|
||||
|
||||
const DockerHistogramToolTip = ({
|
||||
dataKey,
|
||||
active,
|
||||
payload,
|
||||
range,
|
||||
theme,
|
||||
uiTimezone,
|
||||
valueFormatter,
|
||||
labelKey,
|
||||
}: DockerHistogramToolTipProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!payload) return null;
|
||||
if (!active) return null;
|
||||
const format = tooltipDateFormatLookup(range);
|
||||
const ts = payload[0].payload?._id ?? null;
|
||||
const dataPoint = payload[0].payload?.[dataKey] ?? null;
|
||||
|
||||
return (
|
||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
|
||||
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
interface HistogramDockerContainerProps {
|
||||
title: string;
|
||||
rightTitle: string;
|
||||
stats: DockerContainerStats;
|
||||
dataKey: string;
|
||||
strokeColor: string;
|
||||
gradientStartColor: string;
|
||||
range: DateRange;
|
||||
formatter: (value: number) => string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export const HistogramDockerContainer = ({
|
||||
title,
|
||||
rightTitle,
|
||||
stats,
|
||||
dataKey,
|
||||
strokeColor,
|
||||
gradientStartColor,
|
||||
range,
|
||||
formatter,
|
||||
labelKey,
|
||||
}: HistogramDockerContainerProps) => {
|
||||
const uniqueId = useId();
|
||||
const theme = useTheme();
|
||||
const gradientId = `gradient-${uniqueId}`;
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
return (
|
||||
<BaseChart
|
||||
icon="null"
|
||||
title={title}
|
||||
rightTitle={rightTitle}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={200}
|
||||
>
|
||||
<AreaChart data={stats.aggregate}>
|
||||
<CartesianGrid
|
||||
strokeWidth={1}
|
||||
stroke={theme.palette.divider}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Fragment>
|
||||
{createGradient({
|
||||
id: gradientId,
|
||||
startColor: gradientStartColor,
|
||||
endColor: "transparent",
|
||||
direction: "vertical",
|
||||
})}
|
||||
<Tooltip
|
||||
content={(props) => (
|
||||
<DockerHistogramToolTip
|
||||
{...props}
|
||||
range={range}
|
||||
theme={theme}
|
||||
uiTimezone={uiTimezone}
|
||||
dataKey={dataKey}
|
||||
valueFormatter={formatter}
|
||||
labelKey={labelKey}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
type="monotone"
|
||||
stroke={strokeColor}
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</Fragment>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||
|
||||
const AREA_COLORS = [
|
||||
// Blues
|
||||
"#3182bd", // Deep blue
|
||||
"#6baed6", // Medium blue
|
||||
"#9ecae1", // Light blue
|
||||
|
||||
// Greens
|
||||
"#74c476", // Soft green
|
||||
"#a1d99b", // Light green
|
||||
"#c7e9c0", // Pale green
|
||||
|
||||
// Oranges
|
||||
"#fdae6b", // Warm orange
|
||||
"#fdd0a2", // Light orange
|
||||
"#feedde", // Pale orange
|
||||
|
||||
// Purples
|
||||
"#9467bd", // Lavender
|
||||
"#a55194", // Deep magenta
|
||||
"#c994c7", // Soft magenta
|
||||
|
||||
// Reds
|
||||
"#ff9896", // Soft red
|
||||
"#de2d26", // Deep red
|
||||
"#fc9272", // Medium red
|
||||
|
||||
// Cyans/Teals
|
||||
"#17becf", // Cyan
|
||||
"#7fcdbb", // Teal
|
||||
"#a1dab4", // Light teal
|
||||
|
||||
// Yellows
|
||||
"#fec44f", // Mustard
|
||||
"#fee391", // Light yellow
|
||||
"#ffffd4", // Pale yellow
|
||||
|
||||
// Additional colors
|
||||
"#e377c2", // Soft pink
|
||||
"#bcbd22", // Olive
|
||||
"#2ca02c", // Vibrant green
|
||||
];
|
||||
|
||||
const createGradient = ({
|
||||
id,
|
||||
startColor,
|
||||
endColor,
|
||||
startOpacity = 0.8,
|
||||
endOpacity = 0,
|
||||
direction = "vertical",
|
||||
}: {
|
||||
id: string;
|
||||
startColor: string;
|
||||
endColor: string;
|
||||
startOpacity?: number;
|
||||
endOpacity?: number;
|
||||
direction?: "vertical" | "horizontal";
|
||||
}) => (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1={direction === "vertical" ? "0" : "0"}
|
||||
y1={direction === "vertical" ? "0" : "0"}
|
||||
x2={direction === "vertical" ? "0" : "1"}
|
||||
y2={direction === "vertical" ? "1" : "0"}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={startColor}
|
||||
stopOpacity={startOpacity}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={endColor}
|
||||
stopOpacity={endOpacity}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||
|
||||
export const HistogramInfrastructure = ({
|
||||
dateRange,
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
export * from "./HeaderMonitorsSummary";
|
||||
export * from "./BulkActionsBar";
|
||||
export * from "./MonitorListPage";
|
||||
@@ -18,64 +18,95 @@ import {
|
||||
Lock,
|
||||
Users,
|
||||
Tag,
|
||||
Waypoints,
|
||||
Container,
|
||||
} from "lucide-react";
|
||||
|
||||
export const getMenu = (t: Function) => {
|
||||
return [
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
icon: <Icon icon={Globe} />,
|
||||
group: t("components.sidebar.menu.groups.monitoring"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
icon: <Icon icon={Globe} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
path: "infrastructure",
|
||||
icon: <Icon icon={Link} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
group: t("components.sidebar.menu.groups.alerting"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.incidents"),
|
||||
path: "incidents",
|
||||
icon: <Icon icon={AlertTriangle} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.notifications"),
|
||||
path: "notifications",
|
||||
icon: <Icon icon={Bell} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.maintenance"),
|
||||
path: "maintenance",
|
||||
icon: <Icon icon={Wrench} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
path: "infrastructure",
|
||||
icon: <Icon icon={Link} />,
|
||||
group: t("components.sidebar.menu.groups.reporting"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.statusPages"),
|
||||
path: "status",
|
||||
icon: <Icon icon={Wifi} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.checks"),
|
||||
path: "checks",
|
||||
icon: <Icon icon={FileText} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.logs"),
|
||||
path: "logs",
|
||||
icon: <Icon icon={ScrollText} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.notifications"),
|
||||
path: "notifications",
|
||||
icon: <Icon icon={Bell} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.tags"),
|
||||
path: "tags",
|
||||
icon: <Icon icon={Tag} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.checks"),
|
||||
path: "checks",
|
||||
icon: <Icon icon={FileText} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.incidents"),
|
||||
path: "incidents",
|
||||
icon: <Icon icon={AlertTriangle} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.statusPages"),
|
||||
path: "status",
|
||||
icon: <Icon icon={Wifi} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.maintenance"),
|
||||
path: "maintenance",
|
||||
icon: <Icon icon={Wrench} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.logs"),
|
||||
path: "logs",
|
||||
icon: <Icon icon={ScrollText} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.settings"),
|
||||
icon: <Icon icon={Settings} />,
|
||||
path: "settings",
|
||||
group: t("components.sidebar.menu.groups.configuration"),
|
||||
items: [
|
||||
{
|
||||
name: t("components.sidebar.menu.tags"),
|
||||
path: "tags",
|
||||
icon: <Icon icon={Tag} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.proxies"),
|
||||
path: "proxies",
|
||||
icon: <Icon icon={Waypoints} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.settings"),
|
||||
path: "settings",
|
||||
icon: <Icon icon={Settings} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -25,11 +25,10 @@ export const NavItem = ({
|
||||
const iconStroke = selected
|
||||
? theme.palette.sidebar.accent
|
||||
: theme.palette.text.secondary;
|
||||
|
||||
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
||||
const buttonBgHoverColor = selected
|
||||
? theme.palette.action.selected
|
||||
: theme.palette.action.hover;
|
||||
? theme.palette.action.selectedHover
|
||||
: theme.palette.action.rowHover;
|
||||
const fontWeight = selected ? 600 : 400;
|
||||
return (
|
||||
<Tooltip
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import Backdrop from "@mui/material/Backdrop";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||
import { Logo } from "@/Components/sidebar/Logo";
|
||||
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
|
||||
@@ -29,7 +32,7 @@ export const Sidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { width, transition, collapsed } = useSidebar();
|
||||
const { width, transition, transitionTiming, collapsed } = useSidebar();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const menu = getMenu(t);
|
||||
@@ -70,11 +73,11 @@ export const Sidebar = () => {
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
borderRight={`1px solid ${theme.palette.divider}`}
|
||||
width={width}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
sx={{
|
||||
touchAction: "none",
|
||||
transition: transition,
|
||||
zIndex: 1000,
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<List
|
||||
@@ -89,17 +92,52 @@ export const Sidebar = () => {
|
||||
pt={theme.spacing(LAYOUT.MD)}
|
||||
pb={theme.spacing(LAYOUT.MD)}
|
||||
/>
|
||||
{menu.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
return (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
selected={selected}
|
||||
onClick={() => handleNavClick(item.path)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{menu.map((group) => (
|
||||
<Box key={group.group}>
|
||||
<Box
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
component="div"
|
||||
color={theme.palette.text.disabled}
|
||||
fontSize={typographyLevels.s}
|
||||
paddingLeft={theme.spacing(5)}
|
||||
paddingTop={theme.spacing(LAYOUT.SM)}
|
||||
paddingBottom={theme.spacing(LAYOUT.XXS)}
|
||||
whiteSpace="nowrap"
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: `opacity ${transitionTiming}`,
|
||||
}}
|
||||
>
|
||||
{group.group}
|
||||
</Typography>
|
||||
<Divider
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: collapsed ? 1 : 0,
|
||||
transition: `opacity ${transitionTiming}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{group.items.map((item) => {
|
||||
const selected = location.pathname.startsWith(`/${item.path}`);
|
||||
return (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
selected={selected}
|
||||
onClick={() => handleNavClick(item.path)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
<StarPrompt />
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
type ChartType = "histogram" | "line";
|
||||
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
|
||||
type TableName =
|
||||
| "monitors"
|
||||
| "team"
|
||||
| "maintenance"
|
||||
| "infrastructure"
|
||||
| "logs"
|
||||
| "pagespeed"
|
||||
| "docker";
|
||||
|
||||
interface TableState {
|
||||
rowsPerPage: number;
|
||||
@@ -14,9 +21,11 @@ interface SidebarState {
|
||||
|
||||
interface UIState {
|
||||
monitors: TableState;
|
||||
pagespeed: TableState;
|
||||
team: TableState;
|
||||
maintenance: TableState;
|
||||
infrastructure: TableState;
|
||||
docker: TableState;
|
||||
logs: TableState;
|
||||
sidebar: SidebarState;
|
||||
mode: ThemeMode;
|
||||
@@ -37,6 +46,9 @@ const initialState: UIState = {
|
||||
monitors: {
|
||||
rowsPerPage: 10,
|
||||
},
|
||||
pagespeed: {
|
||||
rowsPerPage: 10,
|
||||
},
|
||||
team: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
@@ -46,6 +58,9 @@ const initialState: UIState = {
|
||||
infrastructure: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
docker: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
logs: {
|
||||
rowsPerPage: 15,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { logger } from "@/Utils/logger";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: T;
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { toast, type ToastOptions } from "react-toastify";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const useToast = () => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
return useMemo(() => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "warning" });
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "warning" });
|
||||
|
||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||
}, []);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DefaultHttpMethod,
|
||||
DefaultMonitorMatchMethod,
|
||||
DefaultPageSpeedStrategy,
|
||||
DefaultProxyMode,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
|
||||
@@ -46,6 +47,8 @@ export const getMonitorDefaults = (
|
||||
url: data?.url || "",
|
||||
method: data?.method ?? DefaultHttpMethod,
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
proxyMode: data?.proxyMode ?? DefaultProxyMode,
|
||||
proxyId: data?.proxyId || "",
|
||||
useAdvancedMatching: data?.useAdvancedMatching || false,
|
||||
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
|
||||
expectedValue: data?.expectedValue || "",
|
||||
@@ -74,6 +77,13 @@ export const getMonitorDefaults = (
|
||||
...base,
|
||||
type: "docker",
|
||||
url: data?.url || "",
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
dockerTlsCa: data?.dockerTlsCa || "",
|
||||
dockerTlsCert: data?.dockerTlsCert || "",
|
||||
// The key is write-only; the API never returns it.
|
||||
dockerTlsKey: "",
|
||||
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||
};
|
||||
break;
|
||||
case "game":
|
||||
@@ -141,6 +151,8 @@ export const getMonitorDefaults = (
|
||||
url: "",
|
||||
method: DefaultHttpMethod,
|
||||
ignoreTlsErrors: false,
|
||||
proxyMode: DefaultProxyMode,
|
||||
proxyId: "",
|
||||
useAdvancedMatching: false,
|
||||
matchMethod: DefaultMonitorMatchMethod,
|
||||
expectedValue: "",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from "react";
|
||||
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
|
||||
interface UseProxiesFormOptions {
|
||||
data?: ProxyResponse | null;
|
||||
}
|
||||
|
||||
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
|
||||
return useMemo(() => {
|
||||
const defaults: ProxyFormData = {
|
||||
name: data?.name ?? "",
|
||||
protocol: data?.protocol ?? "http",
|
||||
host: data?.host ?? "",
|
||||
port: data?.port ?? 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
return { schema: proxySchema, defaults };
|
||||
}, [data]);
|
||||
};
|
||||
@@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
||||
checkTTL: data?.checkTTL ?? 30,
|
||||
pagespeedApiKey: "",
|
||||
systemEmailPassword: "",
|
||||
globalProxyEnabled: data?.globalProxyEnabled ?? false,
|
||||
globalProxyId: data?.globalProxyId ?? null,
|
||||
};
|
||||
|
||||
return { schema: settingsSchema, defaults };
|
||||
|
||||
@@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250;
|
||||
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
|
||||
|
||||
// Transition timing for sidebar width changes
|
||||
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
|
||||
|
||||
export const useSidebar = () => {
|
||||
const collapsed = useSelector(
|
||||
@@ -19,6 +20,7 @@ export const useSidebar = () => {
|
||||
expandedWidth: SIDEBAR_WIDTH_VAR,
|
||||
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
||||
transition: SIDEBAR_TRANSITION,
|
||||
transitionTiming: SIDEBAR_TRANSITION_TIMING,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,12 @@ export const HeaderTeamControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={Mail}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={onInviteClick}
|
||||
>
|
||||
{t("common.buttons.inviteMember")}
|
||||
@@ -69,7 +74,12 @@ export const HeaderTeamControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Icon icon={UserPlus} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={UserPlus}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={onAddMemberClick}
|
||||
>
|
||||
{t("common.buttons.addMember")}
|
||||
|
||||
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const Checks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { monitorId } = useParams<{ monitorId?: string }>();
|
||||
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("down");
|
||||
const [page, setPage] = useState<number>(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||
|
||||
@@ -41,12 +41,16 @@ import {
|
||||
DefaultMonitorMatchMethod,
|
||||
MonitorMatchMethods,
|
||||
GeoCheckIntervalOptions,
|
||||
ProxyModes,
|
||||
type ProxyMode,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { AppSettingsResponse } from "@/Types/Settings";
|
||||
import {
|
||||
stepFieldsFor,
|
||||
monitorStepCount,
|
||||
isDockerTlsUrl,
|
||||
type MonitorFormData,
|
||||
} from "@/Validation/monitor";
|
||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||
@@ -56,17 +60,27 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF
|
||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||
import type { ProxyResponse } from "@/Types/Proxy";
|
||||
|
||||
const httpMethodOptions = HttpMethods.map((method) => ({
|
||||
value: method,
|
||||
label: method,
|
||||
}));
|
||||
|
||||
const PEM_FIELD_ROWS = 6;
|
||||
|
||||
// Hides the pasted key while the field is not focused. Ignored by browsers
|
||||
// without -webkit-text-security, which then show the key as plain text.
|
||||
const maskedInputSx = {
|
||||
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
|
||||
};
|
||||
|
||||
interface GeneralSettingsConfig {
|
||||
urlLabel: string;
|
||||
urlPlaceholder: string;
|
||||
namePlaceholder: string;
|
||||
showUrl: boolean;
|
||||
showProxy: boolean;
|
||||
showPort: boolean;
|
||||
showGameSelect: boolean;
|
||||
showSecret: boolean;
|
||||
@@ -87,6 +101,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: true,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -101,6 +116,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -111,10 +127,11 @@ const getGeneralSettingsConfig = (
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
docker: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
|
||||
urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -129,6 +146,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: true,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -143,6 +161,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: true,
|
||||
showGameSelect: true,
|
||||
showSecret: false,
|
||||
@@ -157,6 +176,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: true,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -171,6 +191,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -185,6 +206,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: true,
|
||||
@@ -199,6 +221,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -213,6 +236,7 @@ const getGeneralSettingsConfig = (
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
@@ -240,6 +264,7 @@ const CreateMonitorPage = () => {
|
||||
const firstSegment = pathSegments[0];
|
||||
if (firstSegment === "pagespeed") return "pagespeed";
|
||||
if (firstSegment === "infrastructure") return "hardware";
|
||||
if (firstSegment === "docker") return "docker";
|
||||
return "uptime";
|
||||
}, [location.pathname]);
|
||||
|
||||
@@ -249,7 +274,9 @@ const CreateMonitorPage = () => {
|
||||
? "pagespeed"
|
||||
: pageType === "hardware"
|
||||
? "hardware"
|
||||
: "http";
|
||||
: pageType === "docker"
|
||||
? "docker"
|
||||
: "http";
|
||||
|
||||
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
|
||||
isEditMode ? `/monitors/${monitorId}` : null
|
||||
@@ -258,6 +285,8 @@ const CreateMonitorPage = () => {
|
||||
const { data: notifications } = useGet<Notification[]>("/notifications/team");
|
||||
const { data: games } = useGet<GamesMap>("/monitors/games");
|
||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||
const { data: proxies } = useGet<ProxyResponse[]>("/proxies/team");
|
||||
const { data: appSettings } = useGet<AppSettingsResponse>("/settings");
|
||||
|
||||
const { schema, defaults } = useMonitorForm({
|
||||
data: existingMonitor ?? null,
|
||||
@@ -280,7 +309,9 @@ const CreateMonitorPage = () => {
|
||||
const showStep = (step: number) => isEditMode || currentStep === step;
|
||||
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
const watchedUrl = watch("url") as string;
|
||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||
|
||||
@@ -313,6 +344,8 @@ const CreateMonitorPage = () => {
|
||||
[watchedType, t]
|
||||
);
|
||||
|
||||
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
|
||||
|
||||
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
|
||||
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
@@ -355,6 +388,8 @@ const CreateMonitorPage = () => {
|
||||
navigate("/pagespeed");
|
||||
} else if (pageType === "hardware") {
|
||||
navigate("/infrastructure");
|
||||
} else if (pageType === "docker") {
|
||||
navigate("/docker");
|
||||
} else {
|
||||
navigate("/uptime");
|
||||
}
|
||||
@@ -466,6 +501,38 @@ const CreateMonitorPage = () => {
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const globalProxyName = appSettings?.globalProxy?.name;
|
||||
|
||||
const proxyModeOptions = useMemo(
|
||||
() =>
|
||||
ProxyModes.map((mode) => {
|
||||
const key =
|
||||
mode === "inherit"
|
||||
? globalProxyName
|
||||
? "inheritGlobal"
|
||||
: "inheritDirect"
|
||||
: mode;
|
||||
return {
|
||||
value: mode,
|
||||
label: t(`pages.createMonitor.form.general.option.proxyMode.value.${key}`, {
|
||||
name: globalProxyName,
|
||||
}),
|
||||
description: t(
|
||||
`pages.createMonitor.form.general.option.proxyMode.description.${key}`
|
||||
),
|
||||
};
|
||||
}),
|
||||
[t, globalProxyName]
|
||||
);
|
||||
|
||||
const proxyOptions = useMemo(
|
||||
() =>
|
||||
(proxies ?? []).map((proxy) => ({
|
||||
value: proxy.id,
|
||||
label: `${proxy.name} (${proxy.host}:${proxy.port})`,
|
||||
})),
|
||||
[proxies]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
@@ -518,6 +585,11 @@ const CreateMonitorPage = () => {
|
||||
}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
|
||||
placeholder={generalSettingsConfig.namePlaceholder}
|
||||
/>
|
||||
{/* URL/Host/Container field - not shown for hardware */}
|
||||
{generalSettingsConfig.showUrl && (
|
||||
<FormTextField
|
||||
@@ -527,6 +599,27 @@ const CreateMonitorPage = () => {
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
)}
|
||||
{/* Proxy fields - only shown for HTTP */}
|
||||
{generalSettingsConfig.showProxy && (
|
||||
<>
|
||||
<FormRadioGroup
|
||||
name="proxyMode"
|
||||
options={proxyModeOptions}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.proxyMode.label"
|
||||
)}
|
||||
/>
|
||||
{watchedProxyMode === "custom" && (
|
||||
<FormSelectField
|
||||
name="proxyId"
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.proxy.label"
|
||||
)}
|
||||
options={proxyOptions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Strategy field - only for pagespeed type */}
|
||||
{generalSettingsConfig.showStrategy && (
|
||||
@@ -603,11 +696,64 @@ const CreateMonitorPage = () => {
|
||||
options={dnsRecordTypeOptions}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.dockerTls.title")}
|
||||
subtitle={t("pages.createMonitor.form.dockerTls.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
|
||||
placeholder={generalSettingsConfig.namePlaceholder}
|
||||
name="dockerTlsCa"
|
||||
multiline
|
||||
rows={PEM_FIELD_ROWS}
|
||||
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
|
||||
placeholder={t(
|
||||
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
|
||||
)}
|
||||
slotProps={{ input: { sx: pemInputSx } }}
|
||||
/>
|
||||
<FormTextField
|
||||
name="dockerTlsCert"
|
||||
multiline
|
||||
rows={PEM_FIELD_ROWS}
|
||||
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
|
||||
placeholder={t(
|
||||
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
|
||||
)}
|
||||
slotProps={{ input: { sx: pemInputSx } }}
|
||||
/>
|
||||
<FormTextField
|
||||
name="dockerTlsKey"
|
||||
multiline
|
||||
rows={PEM_FIELD_ROWS}
|
||||
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
|
||||
placeholder={t(
|
||||
"pages.createMonitor.form.dockerTls.option.key.placeholder"
|
||||
)}
|
||||
helperText={
|
||||
existingMonitor?.dockerTlsKeySet
|
||||
? t("pages.createMonitor.form.dockerTls.option.key.stored")
|
||||
: undefined
|
||||
}
|
||||
autoComplete="off"
|
||||
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
|
||||
/>
|
||||
<FormSwitchField
|
||||
name="ignoreTlsErrors"
|
||||
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
@@ -752,6 +898,19 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(1) && watchedType === "docker" && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
||||
rightContent={
|
||||
<FormSwitchField
|
||||
name="dockerLogsEnabled"
|
||||
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(2) &&
|
||||
(watchedType === "http" ||
|
||||
watchedType === "grpc" ||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
|
||||
// Hooks
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
|
||||
|
||||
interface HeaderLogsProps {
|
||||
lines: number;
|
||||
loading: boolean;
|
||||
lastUpdatedAt: number | null;
|
||||
}
|
||||
|
||||
export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (lastUpdatedAt === null) return;
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
|
||||
return () => window.clearInterval(id);
|
||||
}, [lastUpdatedAt]);
|
||||
|
||||
const renderStatus = () => {
|
||||
if (loading) return t("pages.docker.container.logs.loading");
|
||||
if (lastUpdatedAt === null) return null;
|
||||
const ageMs = Math.max(0, now - lastUpdatedAt);
|
||||
return ageMs < MS_PER_MINUTE
|
||||
? t("pages.docker.container.logs.updatedSecondsAgo", {
|
||||
count: Math.floor(ageMs / MS_PER_SECOND),
|
||||
})
|
||||
: t("pages.docker.container.logs.updatedMinutesAgo", {
|
||||
count: Math.floor(ageMs / MS_PER_MINUTE),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.MD}
|
||||
>
|
||||
<Typography>
|
||||
{t("pages.docker.container.logs.recentLines", { count: lines })}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={SPACING.SM}
|
||||
>
|
||||
{lastUpdatedAt !== null && !loading && (
|
||||
<Dot
|
||||
color={theme.palette.success.main}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<Typography color={theme.palette.text.secondary}>{renderStatus()}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
import type { DockerLogRow } from "@/Utils/MonitorUtils";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/store";
|
||||
|
||||
export const RowLog = ({ row }: { row: DockerLogRow }) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Grid
|
||||
container
|
||||
spacing={{ xs: 0, md: LAYOUT.MD }}
|
||||
py={{ xs: LAYOUT.LG, md: 0 }}
|
||||
>
|
||||
<Grid size={{ xs: 12, md: "auto" }}>
|
||||
<Typography
|
||||
fontFamily="monospace"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: "grow" }}>
|
||||
<Typography
|
||||
fontFamily={"monospace"}
|
||||
whiteSpace={{ xs: "pre-wrap", md: "nowrap" }}
|
||||
sx={{
|
||||
overflowWrap: { xs: "anywhere", md: "normal" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={"span"}
|
||||
color={
|
||||
row.stream === "stderr"
|
||||
? theme.palette.error.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
>
|
||||
{row.stream}
|
||||
</Box>
|
||||
: {row.text}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{row.gapBefore ? (
|
||||
<Stack
|
||||
width="100%"
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
py={SPACING.MD}
|
||||
>
|
||||
<Typography pr={SPACING.MD}>
|
||||
{t("pages.docker.container.logs.skipped")}
|
||||
</Typography>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Stack>
|
||||
) : (
|
||||
<Divider sx={{ display: { xs: "block", md: "none" } }} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { StatBox } from "@/Components/design-elements";
|
||||
|
||||
// Types
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainerInfo } from "@/Types/Check";
|
||||
|
||||
// Utils
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { getDockerPalette } from "@/Utils/MonitorUtils";
|
||||
|
||||
export const DockerContainerStatusBoxes = ({
|
||||
container,
|
||||
}: {
|
||||
container: DockerContainerInfo | undefined;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
const { state, restartCount, health } = container;
|
||||
const startedAt = container.startedAt ?? "0";
|
||||
const runningFor = Date.now() - new Date(startedAt).getTime();
|
||||
const palette = getDockerPalette(state);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
flexWrap={"wrap"}
|
||||
>
|
||||
<StatBox
|
||||
title={t("common.labels.state")}
|
||||
subtitle={state}
|
||||
palette={palette}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.uptime")}
|
||||
subtitle={formatDuration(runningFor)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.restarts")}
|
||||
subtitle={String(restartCount ?? 0)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.health")}
|
||||
subtitle={health}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
// Components
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, EmptyState } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
|
||||
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
|
||||
import type { DockerLog } from "@/Types/Check";
|
||||
import type { ApiResponse } from "@/Hooks/UseApi";
|
||||
|
||||
// Hooks
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
|
||||
// Utils
|
||||
import { get } from "@/Utils/ApiClient";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
|
||||
|
||||
interface TabLogsProps {
|
||||
monitorId?: string;
|
||||
containerName?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const SCROLL_PIN_THRESHOLD_PX = 8;
|
||||
const TAIL_POLL_MS = 10_000;
|
||||
|
||||
const isScrolledToBottom = (el: HTMLElement) =>
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
|
||||
|
||||
const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
|
||||
const res = await get<ApiResponse<DockerContainerLogsResponse>>(url, { params });
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { toastError } = useToast();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// logs is newest-first, matching the API and what flattenDockerLogs expects.
|
||||
const [logs, setLogs] = useState<DockerLog[]>([]);
|
||||
const [olderExhausted, setOlderExhausted] = useState(false);
|
||||
const [loadingInitial, setLoadingInitial] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
|
||||
|
||||
const logsUrl =
|
||||
isAdmin && enabled && monitorId && containerName
|
||||
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
|
||||
: null;
|
||||
|
||||
const newestCursor = logs[0]?.checkedAt;
|
||||
const oldestCursor = logs[logs.length - 1]?.checkedAt;
|
||||
|
||||
// Initial page. Resets everything when the container changes.
|
||||
useEffect(() => {
|
||||
setLogs([]);
|
||||
setOlderExhausted(false);
|
||||
if (!logsUrl) return;
|
||||
let cancelled = false;
|
||||
setLoadingInitial(true);
|
||||
fetchLogs(logsUrl, {})
|
||||
.then((page) => {
|
||||
if (cancelled) return;
|
||||
setLogs(page.logs);
|
||||
setOlderExhausted(page.nextCursor === null);
|
||||
setLastUpdatedAt(Date.now());
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
toastError(t("pages.docker.container.logs.loadError"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingInitial(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logsUrl, t, toastError]);
|
||||
|
||||
const newestCursorRef = useRef(newestCursor);
|
||||
newestCursorRef.current = newestCursor;
|
||||
|
||||
useEffect(() => {
|
||||
if (!logsUrl) return;
|
||||
let inFlight = false;
|
||||
const tick = async () => {
|
||||
if (inFlight || document.hidden) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const cursor = newestCursorRef.current;
|
||||
const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
|
||||
if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
|
||||
setLastUpdatedAt(Date.now());
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
const id = window.setInterval(tick, TAIL_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [logsUrl]);
|
||||
|
||||
const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
|
||||
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedToBottom = useRef(true);
|
||||
const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
|
||||
// Always pin to bottom when container/monitor changes
|
||||
useLayoutEffect(() => {
|
||||
pinnedToBottom.current = true;
|
||||
}, [monitorId, containerName]);
|
||||
|
||||
// Rows changed: restore position after a prepend, otherwise honour the pin
|
||||
useLayoutEffect(() => {
|
||||
const el = logBoxRef.current;
|
||||
if (!el) return;
|
||||
const anchor = prependAnchor.current;
|
||||
if (anchor) {
|
||||
prependAnchor.current = null;
|
||||
el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
|
||||
return;
|
||||
}
|
||||
if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
|
||||
}, [rows]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!logsUrl || !oldestCursor || loadingOlder) return;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const page = await fetchLogs(logsUrl, { before: oldestCursor });
|
||||
const el = logBoxRef.current;
|
||||
if (el)
|
||||
prependAnchor.current = {
|
||||
scrollHeight: el.scrollHeight,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
setLogs((prev) => [...prev, ...page.logs]);
|
||||
setOlderExhausted(page.nextCursor === null);
|
||||
} catch {
|
||||
toastError(t("pages.docker.container.logs.loadOlderError"));
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.forbidden.title")}
|
||||
description={t("pages.docker.container.logs.forbidden.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.disabled.title")}
|
||||
description={t("pages.docker.container.logs.disabled.description")}
|
||||
actionText={t("pages.docker.container.logs.disabled.action")}
|
||||
actionTo={`/docker/configure/${monitorId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastUpdatedAt !== null && rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.empty.title")}
|
||||
description={t("pages.docker.container.logs.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={LAYOUT.MD}>
|
||||
<HeaderLogs
|
||||
lines={rows.length}
|
||||
loading={loadingInitial}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
/>
|
||||
<BaseBox
|
||||
ref={logBoxRef}
|
||||
onScroll={handleScroll}
|
||||
minHeight={240}
|
||||
maxHeight="50vh"
|
||||
overflow="auto"
|
||||
padding={LAYOUT.MD}
|
||||
sx={{ overflowAnchor: "none" }}
|
||||
>
|
||||
{rows.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
pb={LAYOUT.MD}
|
||||
>
|
||||
{olderExhausted ? (
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.docker.container.logs.noOlder")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={loadingOlder}
|
||||
onClick={loadOlder}
|
||||
>
|
||||
{loadingOlder
|
||||
? t("pages.docker.container.logs.loadingOlder")
|
||||
: t("pages.docker.container.logs.loadOlder")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
return (
|
||||
<RowLog
|
||||
key={row.key}
|
||||
row={row}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BaseBox>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
// Components
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
|
||||
|
||||
// Hooks
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
// Types
|
||||
import type { Theme } from "@mui/material";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
|
||||
{
|
||||
title: t("common.charts.labels.cpuUsage"),
|
||||
key: "avgCpuPct",
|
||||
color: theme.palette.success.main,
|
||||
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
|
||||
formatter: formatPercentage,
|
||||
labelKey: "common.charts.histogram.avgCpuUsage",
|
||||
},
|
||||
{
|
||||
title: t("common.charts.labels.memoryUsage"),
|
||||
key: "avgMemoryUsedBytes",
|
||||
color: theme.palette.primary.main,
|
||||
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
||||
formatter: prettyBytes,
|
||||
labelKey: "common.charts.histogram.avgMemoryUsage",
|
||||
},
|
||||
];
|
||||
|
||||
export const TabOverview = ({
|
||||
stats,
|
||||
range,
|
||||
}: {
|
||||
stats: DockerContainerStats;
|
||||
range: DateRange;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const { ports, mounts } = stats?.latest?.container ?? {};
|
||||
const dedupedPorts = dedupeDockerPorts(ports ?? []);
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={LAYOUT.MD}
|
||||
>
|
||||
{getChartConfigs(theme, t, stats).map((config) => {
|
||||
return (
|
||||
<Grid
|
||||
key={config.key}
|
||||
size={isSmall ? 12 : 6}
|
||||
>
|
||||
<HistogramDockerContainer
|
||||
title={config.title}
|
||||
rightTitle={config.rightTitle}
|
||||
stats={stats}
|
||||
dataKey={config.key}
|
||||
strokeColor={config.color}
|
||||
gradientStartColor={config.color}
|
||||
range={range}
|
||||
formatter={config.formatter}
|
||||
labelKey={config.labelKey}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
<Grid size={isSmall ? 12 : 6}>
|
||||
<BaseBox
|
||||
padding={LAYOUT.MD}
|
||||
minHeight={200}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
textTransform={"uppercase"}
|
||||
>
|
||||
Ports
|
||||
</Typography>
|
||||
<Stack>
|
||||
{dedupedPorts?.map((port) => {
|
||||
return (
|
||||
<Typography
|
||||
key={`${port.publicPort}/${port.privatePort}`}
|
||||
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
</Grid>
|
||||
<Grid size={isSmall ? 12 : 6}>
|
||||
<BaseBox
|
||||
padding={LAYOUT.MD}
|
||||
minHeight={200}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
textTransform={"uppercase"}
|
||||
>
|
||||
Volumes
|
||||
</Typography>
|
||||
<Stack>
|
||||
{mounts?.map((mount) => {
|
||||
return (
|
||||
<Typography key={`${mount.source}/${mount.destination}`}>
|
||||
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
// Components
|
||||
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
|
||||
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
|
||||
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
|
||||
|
||||
// Hooks
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { DockerContainerResponse } from "@/Types/Monitor";
|
||||
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
import { HeaderTimeRange } from "@/Components/common";
|
||||
interface DockerContainerDetailsProps {
|
||||
open?: "overview" | "logs";
|
||||
}
|
||||
|
||||
const TAB_MAP = {
|
||||
overview: 0,
|
||||
logs: 1,
|
||||
} as const;
|
||||
|
||||
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
|
||||
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const { monitorId, containerName } = useParams<{
|
||||
monitorId: string;
|
||||
containerName: string;
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const containerDetailsUrl = monitorId
|
||||
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
|
||||
: null;
|
||||
|
||||
const { data: containerData } = useGet<DockerContainerResponse>(
|
||||
containerDetailsUrl,
|
||||
{},
|
||||
{ refreshInterval: 10000, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const monitor = containerData?.monitor;
|
||||
if (!monitor) return null;
|
||||
|
||||
const stats = containerData.stats;
|
||||
|
||||
const container = stats?.latest?.container;
|
||||
if (!container) return null;
|
||||
|
||||
const { state, name, image } = container;
|
||||
|
||||
return (
|
||||
<BasePage headerKey="docker.container">
|
||||
<DockerStateLabel
|
||||
state={state}
|
||||
name={name}
|
||||
image={image}
|
||||
/>
|
||||
<DockerContainerStatusBoxes container={container} />
|
||||
<HeaderTimeRange
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue: number) => setActiveTab(newValue)}
|
||||
>
|
||||
<Tab label={t("pages.docker.container.tabs.overview")} />
|
||||
<Tab label={t("pages.docker.container.tabs.logs")} />
|
||||
</Tabs>
|
||||
{activeTab === 0 && (
|
||||
<TabOverview
|
||||
stats={stats}
|
||||
range={dateRange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 1 && (
|
||||
<TabLogs
|
||||
monitorId={monitorId}
|
||||
containerName={containerName}
|
||||
enabled={monitor.dockerLogsEnabled ?? false}
|
||||
/>
|
||||
)}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerDetailsPage;
|
||||
@@ -0,0 +1,123 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { DockerStateLabel, Table } from "@/Components/design-elements";
|
||||
// Hooks
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Types
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { DockerContainerInfo } from "@/Types/Check";
|
||||
|
||||
// Utils
|
||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
|
||||
interface DockerContainersTableProps {
|
||||
monitorId: string | undefined;
|
||||
containers: DockerContainerInfo[];
|
||||
onRowClick?: (container: DockerContainerInfo) => void;
|
||||
}
|
||||
|
||||
export const DockerContainersTable = ({
|
||||
monitorId,
|
||||
containers,
|
||||
}: DockerContainersTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getHeaders = () => {
|
||||
// const renderSortIcon = (isActive: boolean) => (
|
||||
// <Box
|
||||
// width={16}
|
||||
// display="inline-flex"
|
||||
// justifyContent="center"
|
||||
// >
|
||||
// {isActive ? (
|
||||
// sortOrder === "asc" ? (
|
||||
// <ArrowUp size={16} />
|
||||
// ) : (
|
||||
// <ArrowDown size={16} />
|
||||
// )
|
||||
// ) : null}
|
||||
// </Box>
|
||||
// );
|
||||
const headers: Header<DockerContainerInfo>[] = [
|
||||
{
|
||||
id: "container",
|
||||
content: t("pages.docker.host.table.headers.container"),
|
||||
render: (row) => {
|
||||
return (
|
||||
<DockerStateLabel
|
||||
state={row.state}
|
||||
name={row.name}
|
||||
image={row.image}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("common.table.headers.status"),
|
||||
render: (row) => {
|
||||
return <Typography>{row.state}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "health",
|
||||
content: t("common.labels.health"),
|
||||
render: (row) => {
|
||||
return <Typography>{row.health}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
content: t("pages.docker.host.table.headers.cpu"),
|
||||
render: (row) => {
|
||||
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
content: t("pages.docker.host.table.headers.memory"),
|
||||
render: (row) => {
|
||||
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "restarts",
|
||||
content: t("common.labels.restarts"),
|
||||
render: (row) => {
|
||||
return <Typography>{row.restartCount}</Typography>;
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
let headers = getHeaders();
|
||||
|
||||
if (isSmall) {
|
||||
headers = headers.filter((h) => h.id !== "histogram");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={containers}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/docker/host/${monitorId}/container/${row.name}`);
|
||||
}}
|
||||
// getRowSx={(row) => ({
|
||||
// backgroundColor: isRowSelected(row.id)
|
||||
// ? theme.palette.action.selected
|
||||
// : "inherit",
|
||||
// })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { StatBox } from "@/Components/design-elements";
|
||||
|
||||
// Types
|
||||
import type { DockerStats } from "@/Types/Monitor";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
|
||||
export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(8)}
|
||||
flexWrap={"wrap"}
|
||||
>
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.containers")}
|
||||
subtitle={String(total)}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.running")}
|
||||
subtitle={String(running)}
|
||||
/>
|
||||
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.stopped")}
|
||||
subtitle={String(stopped)}
|
||||
/>
|
||||
|
||||
<StatBox
|
||||
title={t("pages.docker.host.statBoxes.unhealthy")}
|
||||
subtitle={String(unhealthy)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BasePage } from "@/Components/design-elements";
|
||||
import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
|
||||
import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
|
||||
|
||||
// Types
|
||||
import type { DockerDetailsResponse } from "@/Types/Monitor";
|
||||
|
||||
// Hooks
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
|
||||
|
||||
const DockerHostDetailsPage = () => {
|
||||
const { monitorId } = useParams<{ monitorId: string }>();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// The containers list renders the latest check only, so no date range is
|
||||
// offered; the endpoint's dateRange param only shapes the aggregate buckets.
|
||||
const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
|
||||
|
||||
const { data: monitorDetailsData, refetch: refetchMonitor } =
|
||||
useGet<DockerDetailsResponse>(
|
||||
monitorDetailsUrl,
|
||||
{},
|
||||
{ refreshInterval: 10000, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const monitor = monitorDetailsData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
const stats = monitorDetailsData?.stats;
|
||||
const containers = stats?.latest?.containers ?? [];
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<HeaderMonitorControls
|
||||
path="docker"
|
||||
monitor={monitor}
|
||||
isAdmin={isAdmin}
|
||||
refetch={refetchMonitor}
|
||||
/>
|
||||
<MonitorStatBoxes
|
||||
monitor={monitor}
|
||||
monitorStats={monitorStats}
|
||||
/>
|
||||
<DockerStatusBoxes stats={stats} />
|
||||
<DockerContainersTable
|
||||
monitorId={monitorId}
|
||||
containers={containers}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerHostDetailsPage;
|
||||
@@ -0,0 +1,343 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
ColoredLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import type { DockerContainerSummary } from "@/Types/Check";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
|
||||
const recentChecks = monitor.recentChecks ?? [];
|
||||
return recentChecks[recentChecks.length - 1]?.containerSummary;
|
||||
};
|
||||
|
||||
interface DockerMonitorsTableProps {
|
||||
monitors: Monitor[];
|
||||
tags: Tag[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const DockerMonitorsTable = ({
|
||||
monitors,
|
||||
tags,
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: DockerMonitorsTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
newPage: number
|
||||
) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
|
||||
) => {
|
||||
const value = Number(e.target.value);
|
||||
setPage(0);
|
||||
setRowsPerPage(value);
|
||||
};
|
||||
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
||||
setSortOrder(newOrder);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
refetch();
|
||||
};
|
||||
|
||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||
return [
|
||||
{
|
||||
id: 2,
|
||||
label: t("pages.common.monitors.actions.details"),
|
||||
action: () => {
|
||||
navigate(`${monitor.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: t("pages.common.monitors.actions.incidents"),
|
||||
action: () => {
|
||||
navigate(`/incidents?monitorId=${monitor.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: t("pages.common.monitors.actions.configure"),
|
||||
action: () => {
|
||||
navigate(`/docker/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
monitor.isActive === false
|
||||
? t("common.buttons.resume")
|
||||
: t("common.buttons.pause"),
|
||||
action: async () => {
|
||||
await post(`/monitors/pause/${monitor.id}`, {});
|
||||
refetch();
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
label: (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("common.buttons.delete")}
|
||||
</Typography>
|
||||
),
|
||||
action: () => {
|
||||
setSelectedMonitor(monitor);
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getHeaders = () => {
|
||||
const renderSortIcon = (isActive: boolean) => (
|
||||
<Box
|
||||
width={16}
|
||||
display="inline-flex"
|
||||
justifyContent="center"
|
||||
>
|
||||
{isActive ? (
|
||||
sortOrder === "asc" ? (
|
||||
<ArrowUp size={16} />
|
||||
) : (
|
||||
<ArrowDown size={16} />
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("pages.docker.table.headers.host"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
onClick={(e) => handleSort(e, "name")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
{t("pages.docker.table.headers.host")}
|
||||
{renderSortIcon(sortField === "name")}
|
||||
</Typography>
|
||||
),
|
||||
render: (row) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Typography variant="body2">{row.name}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{row.url}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "containers",
|
||||
content: t("pages.docker.table.headers.containers"),
|
||||
render: (row) => {
|
||||
const summary = summaryOf(row);
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
{summary
|
||||
? t("pages.docker.table.containersRunning", {
|
||||
running: summary.running,
|
||||
total: summary.total,
|
||||
})
|
||||
: "—"}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
content: t("common.table.headers.tags"),
|
||||
render: (row) => {
|
||||
if (row.tags.length === 0) return "-";
|
||||
return (
|
||||
<Stack
|
||||
justifyContent={"center"}
|
||||
direction={isSmall ? "column" : "row"}
|
||||
gap={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
{row.tags.map((tagId) => {
|
||||
const fullTag = tags.find((t) => t.id === tagId);
|
||||
if (!fullTag) return null;
|
||||
return (
|
||||
<ColoredLabel
|
||||
key={fullTag.id}
|
||||
text={fullTag.name}
|
||||
color={fullTag.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
content: t("common.table.headers.actions"),
|
||||
render: (row) => {
|
||||
return <ActionsMenu items={getActions(row)} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
let headers = getHeaders();
|
||||
|
||||
if (isSmall) {
|
||||
headers = headers.filter((h) => h.id !== "histogram");
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Table
|
||||
headers={headers}
|
||||
data={monitors}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/docker/host/${row.id}`);
|
||||
}}
|
||||
isRowSelected={(row) => isRowSelected(row.id)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={count}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
onRowsPerPageChange={handleRowsPerPageChange}
|
||||
itemsOnPage={monitors.length}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MonitorListPage } from "@/Components/monitors";
|
||||
import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const DockerMonitorsPage = () => {
|
||||
const c = useMonitorListController({
|
||||
types: ["docker"],
|
||||
checksLimit: 25,
|
||||
refreshInterval: 5000,
|
||||
rowsPerPageTable: "docker",
|
||||
rowsPerPageDefault: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<MonitorListPage
|
||||
headerKey="docker"
|
||||
page="docker"
|
||||
actionLink="/docker/create"
|
||||
controller={c}
|
||||
bulkActions
|
||||
showTypeFilter={false}
|
||||
>
|
||||
<DockerMonitorsTable
|
||||
monitors={c.monitors || []}
|
||||
tags={c.tags || []}
|
||||
refetch={c.refetch}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerMonitorsPage;
|
||||
@@ -30,6 +30,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ "& svg": { color: theme.palette.text.secondary } }}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="body1">{label}</Typography>
|
||||
@@ -143,7 +144,10 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Icon icon={Globe} />
|
||||
<Icon
|
||||
icon={Globe}
|
||||
color={theme.palette.text.secondary}
|
||||
/>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
const IncidentsPage = () => {
|
||||
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
|
||||
// Filter state
|
||||
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
|
||||
|
||||
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
|
||||
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
||||
return [
|
||||
{
|
||||
title: t("pages.infrastructure.charts.labels.memory"),
|
||||
title: t("common.charts.labels.memoryUsage"),
|
||||
type: "memory",
|
||||
dataKeys: ["avgMemoryUsage"],
|
||||
strokeColor: theme.palette.primary.main,
|
||||
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
|
||||
idx: null,
|
||||
},
|
||||
{
|
||||
title: t("pages.infrastructure.charts.labels.cpu"),
|
||||
title: t("common.charts.labels.cpuUsage"),
|
||||
type: "cpu",
|
||||
dataKeys: ["avgCpuUsage"],
|
||||
strokeColor: theme.palette.success.main,
|
||||
|
||||
@@ -63,19 +63,42 @@ const getChartConfigs = (
|
||||
return configs;
|
||||
};
|
||||
|
||||
// Loopback / bridge names Capture sees when it can only observe the container's
|
||||
// own network namespace. When ALL reported interfaces match this set we assume
|
||||
// the "no real interfaces visible" case and surface the notice.
|
||||
// Loopback / bridge names Capture reports when it can only observe the
|
||||
// container's own network namespace.
|
||||
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
|
||||
|
||||
// Below this, an interface is carrying no traffic worth charting. Real host
|
||||
// NICs sit orders of magnitude above it; a container's veth sits at or near
|
||||
// zero.
|
||||
const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
|
||||
|
||||
const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
|
||||
checks.every((check) => {
|
||||
const iface = check.net?.find((candidate) => candidate.name === name);
|
||||
if (!iface) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
|
||||
(iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
|
||||
);
|
||||
});
|
||||
|
||||
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
|
||||
const named = new Set<string>();
|
||||
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
|
||||
if (named.size === 0) return false;
|
||||
for (const name of named) {
|
||||
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
|
||||
if (named.size === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
const names = [...named];
|
||||
if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
|
||||
return true;
|
||||
}
|
||||
if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
|
||||
return false;
|
||||
}
|
||||
return names.every((name) => interfaceIsIdle(checks, name));
|
||||
};
|
||||
|
||||
export const InfraNetworkCharts = ({
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTheme } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
|
||||
const GAUGE_MAX_WIDTH = 260;
|
||||
|
||||
export const InfraDetailsGauges = ({
|
||||
snapshot,
|
||||
}: {
|
||||
@@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({
|
||||
direction={isSmall ? "column" : "row"}
|
||||
spacing={theme.spacing(8)}
|
||||
alignItems={"stretch"}
|
||||
flexWrap={"wrap"}
|
||||
useFlexGap
|
||||
>
|
||||
<DetailGauge
|
||||
title={t("pages.infrastructure.gauges.memory.title")}
|
||||
maxWidth={260}
|
||||
maxWidth={GAUGE_MAX_WIDTH}
|
||||
progress={(snapshot?.memory?.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
|
||||
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
|
||||
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
|
||||
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||
/>
|
||||
<DetailGauge
|
||||
title={t("pages.infrastructure.gauges.cpu.title")}
|
||||
maxWidth={260}
|
||||
maxWidth={GAUGE_MAX_WIDTH}
|
||||
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
|
||||
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
|
||||
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
|
||||
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||
/>
|
||||
{snapshot?.disk?.map((disk, idx) => {
|
||||
return (
|
||||
<DetailGauge
|
||||
key={disk?.device || 0 + idx}
|
||||
key={disk?.device || `disk-${idx}`}
|
||||
// title={`Disk ${idx} usage`}
|
||||
title={t("pages.infrastructure.gauges.disk.title", { idx })}
|
||||
maxWidth={260}
|
||||
maxWidth={GAUGE_MAX_WIDTH}
|
||||
progress={(disk.usage_percent || 0) * 100}
|
||||
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
|
||||
upperValue={prettyBytes(disk?.used_bytes || 0)}
|
||||
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
|
||||
lowerValue={prettyBytes(disk?.total_bytes || 0)}
|
||||
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
|
||||
import type { HardwareDetailsResponse } from "@/Types/Monitor";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const InfrastructureDetails = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
|
||||
|
||||
const { monitorId } = useParams<{ monitorId: string }>();
|
||||
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
|
||||
@@ -331,11 +331,7 @@ export const InfraMonitorsTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/infrastructure/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
isRowSelected={(row) => isRowSelected(row.id)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,274 +1,44 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
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 { MonitorListPage } from "@/Components/monitors";
|
||||
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
|
||||
|
||||
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";
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const InfrastructureMonitors = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isAdmin = useIsAdmin();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
const c = useMonitorListController({
|
||||
types: ["hardware"],
|
||||
checksLimit: 1,
|
||||
refreshInterval: 5000,
|
||||
rowsPerPageTable: "infrastructure",
|
||||
rowsPerPageDefault: 5,
|
||||
});
|
||||
return (
|
||||
<MonitorBasePageWithStates
|
||||
<MonitorListPage
|
||||
headerKey="infrastructure"
|
||||
loading={isLoading}
|
||||
error={monitorsWithChecksError}
|
||||
totalCount={effectiveTotalCount}
|
||||
page="infrastructure"
|
||||
actionLink="/infrastructure/create"
|
||||
controller={c}
|
||||
bulkActions
|
||||
showTypeFilter={false}
|
||||
summaryProps={{ showBreached: true }}
|
||||
>
|
||||
<HeaderCreate
|
||||
path="/infrastructure/create"
|
||||
isLoading={isLoading}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<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}
|
||||
monitors={c.monitors || []}
|
||||
tags={c.tags || []}
|
||||
refetch={c.refetch}
|
||||
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}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
</MonitorListPage>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { BasePage, ConfigBox } from "@/Components/design-elements";
|
||||
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
|
||||
import { useTheme } from "@mui/material";
|
||||
@@ -15,6 +16,7 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import { mutate } from "swr";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
|
||||
@@ -41,6 +43,7 @@ const CreateMaintenanceWindowPage = () => {
|
||||
);
|
||||
|
||||
const { data: monitors } = useGet<Monitor[]>("/monitors/team");
|
||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||
|
||||
const { post, loading: isPosting } = usePost();
|
||||
const { patch, loading: isPatching } = usePatch();
|
||||
@@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => {
|
||||
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
|
||||
)}
|
||||
options={monitors ?? []}
|
||||
renderOptionContent={(monitor) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Typography>{monitor.name}</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;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
|
||||
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
|
||||
alignItems="center"
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={NotFoundSvg}
|
||||
alt="404"
|
||||
maxHeight={"25rem"}
|
||||
component={NotFoundSvg}
|
||||
role="img"
|
||||
aria-label="404"
|
||||
width="100%"
|
||||
maxHeight="25rem"
|
||||
/>
|
||||
<Typography
|
||||
component="h1"
|
||||
|
||||
@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "signalgrid" &&
|
||||
watchedType !== "twilio" &&
|
||||
watchedType !== "ntfy" && (
|
||||
<ConfigBox
|
||||
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "signalgrid" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.signalgrid.title")}
|
||||
subtitle={t("pages.notifications.form.signalgrid.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<FormTextField
|
||||
name="accessToken"
|
||||
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.signalgrid.placeholderClientKey"
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name="address"
|
||||
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.signalgrid.placeholderChannel"
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "twilio" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.twilio.title")}
|
||||
|
||||
@@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useState, useMemo } from "react";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const PageSpeedDetails = () => {
|
||||
const { monitorId } = useParams();
|
||||
const isAdmin = useIsAdmin();
|
||||
const theme = useTheme();
|
||||
const [dateRange, setDateRange] = useState<string>("day");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("day");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
if (!monitorId) {
|
||||
|
||||
@@ -1,229 +1,57 @@
|
||||
import {
|
||||
MonitorBasePageWithStates,
|
||||
PageSpeedKeyPriorityFallback,
|
||||
ColoredLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { Dialog, TextField } from "@/Components/inputs";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
|
||||
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 { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { AppSettingsResponse } from "@/Types/Settings";
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import { MonitorListPage } from "@/Components/monitors";
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const PageSpeedMonitorsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isAdmin = useIsAdmin();
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
const settingsUrl = "/settings";
|
||||
const {
|
||||
data: settingsData,
|
||||
isLoading: settingsIsLoading,
|
||||
error: settingsError,
|
||||
} = useGet<AppSettingsResponse>(settingsUrl);
|
||||
} = useGet<AppSettingsResponse>("/settings");
|
||||
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
const [sortField, setSortField] = useState<string>("name");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
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 c = useMonitorListController({
|
||||
types: ["pagespeed"],
|
||||
checksLimit: 25,
|
||||
refreshInterval: 30000,
|
||||
rowsPerPageTable: "pagespeed",
|
||||
rowsPerPageDefault: 10,
|
||||
initialSortField: "name",
|
||||
});
|
||||
|
||||
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 (
|
||||
<MonitorBasePageWithStates
|
||||
<MonitorListPage
|
||||
headerKey="pageSpeed"
|
||||
loading={isLoading}
|
||||
error={monitorsError || settingsError}
|
||||
totalCount={effectiveTotalCount}
|
||||
page="pageSpeed"
|
||||
actionLink="/pagespeed/create"
|
||||
controller={c}
|
||||
showTypeFilter={false}
|
||||
extraLoading={settingsIsLoading}
|
||||
extraError={settingsError}
|
||||
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
|
||||
>
|
||||
<HeaderCreate
|
||||
path="/pagespeed/create"
|
||||
isLoading={isLoading}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<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}
|
||||
monitors={c.monitors || []}
|
||||
tags={c.tags || []}
|
||||
refetch={c.refetch}
|
||||
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}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
</MonitorListPage>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
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;
|
||||
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
+725
-495
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,12 @@ export const HeaderConfigStatusControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<Icon icon={Trash} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={Trash}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{translate.t("common.buttons.delete")}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
|
||||
import {
|
||||
BasePage,
|
||||
ColoredLabel,
|
||||
ConfigBox,
|
||||
StepProgress,
|
||||
} from "@/Components/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
@@ -16,12 +21,12 @@ import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statu
|
||||
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
|
||||
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 { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import { timezoneOptions } from "@/Utils/timezoneOptions";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { mutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
import { ThemePicker } from "./Components/ThemePicker";
|
||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||
@@ -53,7 +58,9 @@ const LogoUploadField = () => {
|
||||
<ImageUpload
|
||||
src={field.value?.data}
|
||||
onChange={(file) => {
|
||||
field.onChange(file ? { data: file.src, contentType: file.file.type } : null);
|
||||
field.onChange(
|
||||
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -86,6 +93,8 @@ const CreateStatusPage = () => {
|
||||
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
|
||||
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
|
||||
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
|
||||
|
||||
const { post, loading: isSubmittingPost } = usePost();
|
||||
const { put, loading: isSubmittingPut } = usePut();
|
||||
@@ -196,18 +205,8 @@ const CreateStatusPage = () => {
|
||||
if (data.logo === null) {
|
||||
// Signal to remove the logo
|
||||
fd.append("removeLogo", "true");
|
||||
} else if (data.logo?.data && data.logo.data !== "") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else if (data.logo?.file) {
|
||||
fd.append("logo", data.logo.file);
|
||||
}
|
||||
|
||||
let result;
|
||||
@@ -308,6 +307,28 @@ const CreateStatusPage = () => {
|
||||
"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;
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
renderRow={(monitor) => (
|
||||
<Typography flexGrow={1}>
|
||||
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
|
||||
|
||||
@@ -77,7 +77,12 @@ export const HeaderStatusPageControls = ({
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<Icon icon={Settings} />}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={Settings}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
|
||||
>
|
||||
{t("common.buttons.configure")}
|
||||
|
||||
@@ -113,6 +113,7 @@ const StatusPageView = () => {
|
||||
apiUrl,
|
||||
{},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
refreshInterval: range === "latest" ? 10000 : 60000,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
@@ -34,9 +34,9 @@ export interface BoldStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -327,9 +327,12 @@ export const boldStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 48,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
borderRadius: "3px",
|
||||
background: heatCellBg[kind],
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
boxShadow: heatCellShadow[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
@@ -344,8 +347,11 @@ export const boldStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 48,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
borderRadius: "3px",
|
||||
minHeight: 4,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import {
|
||||
EDITORIAL_SECONDARY_SANS_STACK,
|
||||
@@ -38,9 +38,9 @@ export interface EditorialStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -272,8 +272,11 @@ export const editorialStyles = (
|
||||
gap: { xs: "1px", md: "2px" },
|
||||
height: 40,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
background: heatCellBg[kind],
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
}),
|
||||
|
||||
@@ -285,8 +288,11 @@ export const editorialStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 40,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
minHeight: 3,
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
height: `${heightPct}%`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { keyframes } from "@mui/system";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
@@ -37,9 +37,9 @@ export interface ModernStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -83,13 +83,14 @@ export const modernStyles = (
|
||||
): ModernStyles => {
|
||||
const cardShadow = isDark ? darkShadow : lightShadow;
|
||||
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
|
||||
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
|
||||
|
||||
const heatCellBg: Record<ModernHeatCell, string> = {
|
||||
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
|
||||
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -103,7 +104,7 @@ export const modernStyles = (
|
||||
const gaugeFillBg: Record<ModernGaugeFill, string> = {
|
||||
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -354,13 +355,19 @@ export const modernStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 46,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
borderRadius: "3px",
|
||||
background: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.2)" },
|
||||
}),
|
||||
heatmapCell: (kind, severity = 1) => {
|
||||
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
|
||||
return {
|
||||
borderRadius: "3px",
|
||||
background:
|
||||
kind === "degraded"
|
||||
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.2)" },
|
||||
};
|
||||
},
|
||||
|
||||
histogram: {
|
||||
padding: { xs: "0 12px", md: "0 24px" },
|
||||
@@ -370,8 +377,11 @@ export const modernStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 46,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
borderRadius: "3px",
|
||||
minHeight: 4,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
|
||||
@@ -37,9 +37,9 @@ export interface RefinedStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -296,9 +296,12 @@ export const refinedStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 42,
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
borderRadius: "2px",
|
||||
background: heatCellBg[kind],
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.15)" },
|
||||
@@ -312,8 +315,11 @@ export const refinedStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 42,
|
||||
},
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
borderRadius: "2px",
|
||||
minHeight: 3,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -59,9 +59,9 @@ export interface BaseStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -211,6 +211,7 @@ export const BaseStatusPage = ({
|
||||
>
|
||||
<Icon
|
||||
icon={TriangleAlert}
|
||||
size={20}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
<Typography>
|
||||
|
||||
@@ -18,6 +18,7 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt
|
||||
|
||||
export interface ChartCell {
|
||||
key: string;
|
||||
severity: number;
|
||||
barKind: BarKind;
|
||||
heatKind: HeatCellKind;
|
||||
heightPct: number; // histogram bar height
|
||||
@@ -28,9 +29,22 @@ export interface ChartCell {
|
||||
|
||||
const DAY_DOWN_THRESHOLD = 0.5;
|
||||
|
||||
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
|
||||
// Color interpolation
|
||||
// 0 = perfect
|
||||
// 1 = at or below the down threshold
|
||||
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
|
||||
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
|
||||
|
||||
export const severityFromUpRatio = (upRatio: number): number => {
|
||||
const failed = 1 - upRatio;
|
||||
if (failed <= 0) return 0;
|
||||
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
|
||||
const clamped = Math.min(1, Math.max(0, scaled));
|
||||
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
|
||||
};
|
||||
|
||||
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
|
||||
if (bucket.totalChecks === 0) return "empty";
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
if (upRatio >= 1) return "up";
|
||||
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
|
||||
return "degraded";
|
||||
@@ -55,6 +69,7 @@ const emptyCell = (key: string): ChartCell => ({
|
||||
responseTime: 0,
|
||||
tooltip: null,
|
||||
ariaLabel: "",
|
||||
severity: 0,
|
||||
});
|
||||
|
||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||
@@ -76,6 +91,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
||||
responseTime: check.responseTime ?? 0,
|
||||
tooltip: <ThemedChartTooltip check={check} />,
|
||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||
severity: 0,
|
||||
})
|
||||
);
|
||||
return padCells(cells, MAX_RECENT_CHECKS);
|
||||
@@ -93,9 +109,17 @@ export const dailyBucketsToCells = (
|
||||
return enumerated.map((date) => {
|
||||
const bucket = byDate.get(date);
|
||||
if (!bucket) return emptyCell(`day-${date}`);
|
||||
const barKind = classifyDay(bucket);
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
|
||||
const barKind = classifyDay(bucket, upRatio);
|
||||
return {
|
||||
key: `day-${date}`,
|
||||
severity:
|
||||
barKind === "degraded"
|
||||
? severityFromUpRatio(upRatio)
|
||||
: barKind === "down"
|
||||
? 1
|
||||
: 0,
|
||||
barKind,
|
||||
heatKind:
|
||||
barKind === "up"
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={cellSx(cell.heatKind)}
|
||||
sx={cellSx(cell.heatKind, cell.severity)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
statsSx: SxProps<Theme>;
|
||||
statsGap?: number;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={barSx(cell.barKind, cell.heightPct)}
|
||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const SANS_STACK =
|
||||
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
|
||||
|
||||
|
||||
@@ -243,3 +243,10 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
|
||||
editorial,
|
||||
minimal,
|
||||
};
|
||||
|
||||
// Interpolate between a theme's up and degraded colors by severity (0..1).
|
||||
export const mixSeverityColor = (
|
||||
up: string,
|
||||
degraded: string,
|
||||
severity: number
|
||||
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
|
||||
@@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { t } from "i18next";
|
||||
import { Typography } from "@mui/material";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
@@ -53,7 +54,7 @@ const UptimeDetailsPage = () => {
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
|
||||
const [geoPage, setGeoPage] = useState<number>(0);
|
||||
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
|
||||
const [dateRange, setDateRange] = useState<string>("recent");
|
||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
|
||||
|
||||
const monitorDetailsUrl = useMemo(() => {
|
||||
|
||||
@@ -350,11 +350,7 @@ export const MonitorTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/uptime/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
isRowSelected={(row) => isRowSelected(row.id)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,294 +1,43 @@
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { MonitorListPage } from "@/Components/monitors";
|
||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
|
||||
import type { Tag } from "@/Types/Tag";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTheme } from "@mui/material";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const dispatch = useDispatch();
|
||||
const isAdmin = useIsAdmin();
|
||||
// Redux state
|
||||
const rowsPerPage = useSelector(
|
||||
(state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
|
||||
);
|
||||
|
||||
// Local state
|
||||
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>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
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;
|
||||
|
||||
// Default to all types when none selected
|
||||
const effectiveTypes =
|
||||
selectedTypes.length > 0
|
||||
? selectedTypes
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
|
||||
|
||||
// Build URL for monitors with checks
|
||||
const monitorsWithChecksUrl = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
effectiveTypes.forEach((type) => params.append("type", type));
|
||||
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()}`;
|
||||
}, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
|
||||
|
||||
const {
|
||||
data: monitorsWithChecksData,
|
||||
isLoading: monitorsWithChecksLoading,
|
||||
error: monitorsWithChecksError,
|
||||
refetch,
|
||||
} = useGet<MonitorsWithChecksResponse>(
|
||||
monitorsWithChecksUrl,
|
||||
{},
|
||||
{ refreshInterval: 5000, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const { data: tags } = useGet<Tag[]>("/tags/team");
|
||||
|
||||
const {
|
||||
monitors: monitorsWithChecks,
|
||||
summary,
|
||||
count,
|
||||
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Delete hook
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
// Handlers
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setSelectedTypes([]);
|
||||
setSelectedStatus("");
|
||||
setSelectedState("");
|
||||
setSelectedTags([]);
|
||||
setSearch("");
|
||||
}, []);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedMonitor) return;
|
||||
await deleteFn(`/monitors/${selectedMonitor.id}`);
|
||||
setSelectedMonitor(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedMonitor(null);
|
||||
};
|
||||
|
||||
const isLoading = monitorsWithChecksLoading;
|
||||
|
||||
// Check if any filters are active
|
||||
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 effectiveTotalCount =
|
||||
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
|
||||
? 1
|
||||
: (summary?.totalMonitors ?? 0);
|
||||
const monitorListController = useMonitorListController({
|
||||
types: "selectable",
|
||||
checksLimit: 25,
|
||||
refreshInterval: 5000,
|
||||
rowsPerPageTable: "monitors",
|
||||
rowsPerPageDefault: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<MonitorBasePageWithStates
|
||||
<MonitorListPage
|
||||
headerKey="uptime"
|
||||
loading={isLoading}
|
||||
error={monitorsWithChecksError}
|
||||
totalCount={effectiveTotalCount}
|
||||
page="uptime"
|
||||
actionLink="/uptime/create"
|
||||
controller={monitorListController}
|
||||
bulkActions
|
||||
>
|
||||
<HeaderCreate
|
||||
path="/uptime/create"
|
||||
isLoading={isLoading}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
<HeaderMonitorsSummary summary={summary} />
|
||||
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<ControlsFilter
|
||||
selectedTypes={selectedTypes}
|
||||
setSelectedTypes={setSelectedTypes}
|
||||
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>
|
||||
)}
|
||||
|
||||
<MonitorTable
|
||||
monitors={monitorsWithChecks || []}
|
||||
tags={tags || []}
|
||||
refetch={refetch}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
count={count || 0}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
sortField={sortField}
|
||||
setSortField={setSortField}
|
||||
sortOrder={sortOrder}
|
||||
setSortOrder={setSortOrder}
|
||||
setRowsPerPage={(rowsPerPage: number) => {
|
||||
dispatch(
|
||||
setRowsPerPage({
|
||||
value: rowsPerPage,
|
||||
table: "monitors",
|
||||
})
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
monitors={monitorListController.monitors || []}
|
||||
tags={monitorListController.tags || []}
|
||||
refetch={monitorListController.refetch}
|
||||
setSelectedMonitor={monitorListController.setSelectedMonitor}
|
||||
count={monitorListController.count || 0}
|
||||
page={monitorListController.page}
|
||||
setPage={monitorListController.setPage}
|
||||
rowsPerPage={monitorListController.rowsPerPage}
|
||||
sortField={monitorListController.sortField}
|
||||
setSortField={monitorListController.setSortField}
|
||||
sortOrder={monitorListController.sortOrder}
|
||||
setSortOrder={monitorListController.setSortOrder}
|
||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||
selectedRows={monitorListController.selectedRows}
|
||||
onSelectionChange={monitorListController.setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
</MonitorListPage>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
|
||||
import Infrastructure from "@/Pages/Infrastructure/Monitors";
|
||||
import InfrastructureDetails from "@/Pages/Infrastructure/Details";
|
||||
|
||||
// Docker
|
||||
import Docker from "@/Pages/Docker/Monitors";
|
||||
import DockerHostDetails from "@/Pages/Docker/HostDetails";
|
||||
import DockerDetails from "@/Pages/Docker/ContainerDetails";
|
||||
|
||||
// Checks
|
||||
import Checks from "@/Pages/Checks";
|
||||
|
||||
@@ -39,11 +44,14 @@ import CreateNotifications from "@/Pages/Notifications/create";
|
||||
import Tags from "@/Pages/Tags";
|
||||
import CreateTags from "@/Pages/Tags/create";
|
||||
|
||||
// Proxies
|
||||
import Proxies from "@/Pages/Proxies";
|
||||
import CreateProxies from "@/Pages/Proxies/create";
|
||||
|
||||
// Settings
|
||||
import Account from "@/Pages/Account";
|
||||
import EditUser from "@/Pages/Account/EditUser";
|
||||
import Settings from "@/Pages/Settings";
|
||||
|
||||
import Maintenance from "@/Pages/Maintenance";
|
||||
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
|
||||
|
||||
@@ -52,10 +60,8 @@ import Logs from "@/Pages/Logs";
|
||||
|
||||
// Routing
|
||||
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
|
||||
|
||||
import CreateMonitor from "@/Pages/CreateMonitor";
|
||||
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
|
||||
|
||||
const Routes = () => {
|
||||
if (isCustomDomainHost()) {
|
||||
return (
|
||||
@@ -138,6 +144,26 @@ const Routes = () => {
|
||||
path="infrastructure/:monitorId"
|
||||
element={<InfrastructureDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="docker"
|
||||
element={<Docker />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/create"
|
||||
element={<CreateMonitor />}
|
||||
/>
|
||||
<Route
|
||||
path="/docker/configure/:monitorId"
|
||||
element={<CreateMonitor />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/host/:monitorId/container/:containerName"
|
||||
element={<DockerDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="docker/host/:monitorId"
|
||||
element={<DockerHostDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="checks/:monitorId?"
|
||||
element={<Checks />}
|
||||
@@ -188,12 +214,24 @@ const Routes = () => {
|
||||
path="tags/create"
|
||||
element={<CreateTags />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="tags/configure/:tagId"
|
||||
element={<CreateTags />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="proxies"
|
||||
element={<Proxies />}
|
||||
/>
|
||||
<Route
|
||||
path="proxies/create"
|
||||
element={<CreateProxies />}
|
||||
/>
|
||||
<Route
|
||||
path="proxies/configure/:proxyId"
|
||||
element={<CreateProxies />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="maintenance"
|
||||
element={<Maintenance />}
|
||||
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user