Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e5a334bb | ||
|
|
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 | ||
|
|
54ffd74048 | ||
|
|
4bc71a3d18 | ||
|
|
d03765c5fd | ||
|
|
f30085bae3 | ||
|
|
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 |
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).
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
The image is configured entirely through environment variables on the server container:
|
||||
@@ -207,9 +211,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
+68
-2
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.0",
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@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",
|
||||
@@ -361,6 +365,59 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"license": "MIT",
|
||||
@@ -950,6 +1007,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"
|
||||
|
||||
+8
-2
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.10.0",
|
||||
"version": "3.11.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,15 +12,20 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@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,70 @@
|
||||
import { BaseChart } from "@/Components/design-elements";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Fragment, useId } from "react";
|
||||
|
||||
// Utils
|
||||
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||
interface HistogramDockerContainerProps {
|
||||
title: string;
|
||||
rightTitle: string;
|
||||
stats: DockerContainerStats;
|
||||
dataKey: string;
|
||||
strokeColor: string;
|
||||
gradientStartColor: string;
|
||||
}
|
||||
|
||||
export const HistogramDockerContainer = ({
|
||||
title,
|
||||
rightTitle,
|
||||
stats,
|
||||
dataKey,
|
||||
strokeColor,
|
||||
gradientStartColor,
|
||||
}: HistogramDockerContainerProps) => {
|
||||
const uniqueId = useId();
|
||||
const theme = useTheme();
|
||||
const gradientId = `gradient-${uniqueId}`;
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
icon="null"
|
||||
title={title}
|
||||
rightTitle={rightTitle}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={200}
|
||||
>
|
||||
<AreaChart data={stats.aggregate}>
|
||||
<CartesianGrid
|
||||
strokeWidth={1}
|
||||
stroke={theme.palette.divider}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Fragment>
|
||||
{createGradient({
|
||||
id: gradientId,
|
||||
startColor: gradientStartColor,
|
||||
endColor: "transparent",
|
||||
direction: "vertical",
|
||||
})}
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
type="monotone"
|
||||
stroke={strokeColor}
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</Fragment>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Icon } from "@/Components/design-elements";
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Globe,
|
||||
Gauge,
|
||||
Link,
|
||||
@@ -18,64 +19,100 @@ 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.dashboard"),
|
||||
path: "dashboard",
|
||||
icon: <Icon icon={LayoutDashboard} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.uptime"),
|
||||
path: "uptime",
|
||||
icon: <Icon icon={Globe} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
path: "infrastructure",
|
||||
icon: <Icon icon={Link} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.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 />
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
import type { CardId } from "@/Pages/Dashboard/cards";
|
||||
|
||||
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 +22,11 @@ interface SidebarState {
|
||||
|
||||
interface UIState {
|
||||
monitors: TableState;
|
||||
pagespeed: TableState;
|
||||
team: TableState;
|
||||
maintenance: TableState;
|
||||
infrastructure: TableState;
|
||||
docker: TableState;
|
||||
logs: TableState;
|
||||
sidebar: SidebarState;
|
||||
mode: ThemeMode;
|
||||
@@ -26,6 +36,7 @@ interface UIState {
|
||||
language: string;
|
||||
starPromptOpen: boolean;
|
||||
chartType: ChartType;
|
||||
dashboardCards: CardId[] | null;
|
||||
}
|
||||
|
||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||
@@ -37,6 +48,9 @@ const initialState: UIState = {
|
||||
monitors: {
|
||||
rowsPerPage: 10,
|
||||
},
|
||||
pagespeed: {
|
||||
rowsPerPage: 10,
|
||||
},
|
||||
team: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
@@ -46,6 +60,9 @@ const initialState: UIState = {
|
||||
infrastructure: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
docker: {
|
||||
rowsPerPage: 5,
|
||||
},
|
||||
logs: {
|
||||
rowsPerPage: 15,
|
||||
},
|
||||
@@ -59,6 +76,7 @@ const initialState: UIState = {
|
||||
language: "en",
|
||||
starPromptOpen: true,
|
||||
chartType: "histogram",
|
||||
dashboardCards: null,
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
@@ -102,6 +120,9 @@ const uiSlice = createSlice({
|
||||
setChartType: (state, action: PayloadAction<ChartType>) => {
|
||||
state.chartType = action.payload;
|
||||
},
|
||||
setDashboardCards: (state, action: PayloadAction<CardId[] | null>) => {
|
||||
state.dashboardCards = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,4 +139,5 @@ export const {
|
||||
setLanguage,
|
||||
setStarPromptOpen,
|
||||
setChartType,
|
||||
setDashboardCards,
|
||||
} = uiSlice.actions;
|
||||
@@ -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,7 @@ export const getMonitorDefaults = (
|
||||
...base,
|
||||
type: "docker",
|
||||
url: data?.url || "",
|
||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||
};
|
||||
break;
|
||||
case "game":
|
||||
@@ -141,6 +145,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,9 +41,12 @@ 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,
|
||||
@@ -56,6 +59,7 @@ 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,
|
||||
@@ -67,6 +71,7 @@ interface GeneralSettingsConfig {
|
||||
urlPlaceholder: string;
|
||||
namePlaceholder: string;
|
||||
showUrl: boolean;
|
||||
showProxy: boolean;
|
||||
showPort: boolean;
|
||||
showGameSelect: boolean;
|
||||
showSecret: boolean;
|
||||
@@ -87,6 +92,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 +107,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 +118,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 +137,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 +152,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 +167,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 +182,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 +197,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 +212,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 +227,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 +255,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 +265,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 +276,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,
|
||||
@@ -281,6 +301,7 @@ const CreateMonitorPage = () => {
|
||||
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
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;
|
||||
|
||||
@@ -355,6 +376,8 @@ const CreateMonitorPage = () => {
|
||||
navigate("/pagespeed");
|
||||
} else if (pageType === "hardware") {
|
||||
navigate("/infrastructure");
|
||||
} else if (pageType === "docker") {
|
||||
navigate("/docker");
|
||||
} else {
|
||||
navigate("/uptime");
|
||||
}
|
||||
@@ -466,6 +489,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 +573,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 +587,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 +684,6 @@ const CreateMonitorPage = () => {
|
||||
options={dnsRecordTypeOptions}
|
||||
/>
|
||||
)}
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.createMonitor.form.general.option.name.label")}
|
||||
placeholder={generalSettingsConfig.namePlaceholder}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
@@ -752,6 +828,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,123 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { Icon } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
|
||||
import { CardSlotContext } from "./CardSlotContext";
|
||||
|
||||
const SortableCard = ({ card }: { card: CardDefinition }) => {
|
||||
const theme = useTheme();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: card.id });
|
||||
const Component = card.component;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
minWidth={0}
|
||||
sx={{
|
||||
width: {
|
||||
xs: "100%",
|
||||
md:
|
||||
card.width >= GRID_COLUMNS
|
||||
? "100%"
|
||||
: `calc(${(card.width / GRID_COLUMNS) * 100}% - ${theme.spacing(LAYOUT.MD / 2)})`,
|
||||
},
|
||||
transform: transform
|
||||
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
|
||||
: undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
zIndex: isDragging ? 1 : "auto",
|
||||
}}
|
||||
>
|
||||
<CardSlotContext.Provider
|
||||
value={
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
"&:active": { cursor: "grabbing" },
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={GripVertical}
|
||||
size={16}
|
||||
color={theme.palette.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Component />
|
||||
</CardSlotContext.Provider>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardGrid = ({
|
||||
cards,
|
||||
onReorder,
|
||||
}: {
|
||||
cards: CardDefinition[];
|
||||
onReorder: (sourceIndex: number, destIndex: number) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const sourceIndex = cards.findIndex((c) => c.id === active.id);
|
||||
const destIndex = cards.findIndex((c) => c.id === over.id);
|
||||
if (sourceIndex !== -1 && destIndex !== -1) onReorder(sourceIndex, destIndex);
|
||||
};
|
||||
|
||||
const cardIds = cards.map((c) => c.id);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={cardIds}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
alignItems="stretch"
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<SortableCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dialog, Checkbox, Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
|
||||
|
||||
interface CardPickerProps {
|
||||
open: boolean;
|
||||
available: CardDefinition[];
|
||||
selected: CardId[];
|
||||
allSelected: boolean;
|
||||
onAdd: (id: CardId) => void;
|
||||
onRemove: (id: CardId) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards are grouped by sidebar section, not by load cost: you look for a card
|
||||
* where you look for the feature it belongs to. Admin-only cards never reach
|
||||
* `available` for a non-admin, so nobody is offered a card they cannot load.
|
||||
*/
|
||||
export const CardPicker = ({
|
||||
open,
|
||||
available,
|
||||
selected,
|
||||
allSelected,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onReset,
|
||||
onClose,
|
||||
}: CardPickerProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return CardGroups.map((group: CardGroup) => ({
|
||||
group,
|
||||
cards: available.filter((card) => card.group === group),
|
||||
})).filter((entry) => entry.cards.length > 0);
|
||||
}, [available]);
|
||||
|
||||
const chosen = new Set(selected);
|
||||
const someSelected = selected.length > 0 && !allSelected;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
title={t("pages.dashboard.picker.title")}
|
||||
// Reset sits alone on the left, away from Done — a destructive action
|
||||
// should not be adjacent to the one people reach for by reflex.
|
||||
onCancel={onReset}
|
||||
cancelText={t("pages.dashboard.picker.reset")}
|
||||
onConfirm={onClose}
|
||||
confirmText={t("pages.dashboard.picker.done")}
|
||||
additionalButtons={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={allSelected ? onDeselectAll : onSelectAll}
|
||||
startIcon={
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
size="small"
|
||||
sx={{ p: 0 }}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("pages.dashboard.picker.selectAll")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/*
|
||||
* Two columns: seventeen cards in one list makes the dialog scroll,
|
||||
* which hides most of the catalogue. Groups flow down the columns and
|
||||
* are kept whole so a group is never split across the gap.
|
||||
*/}
|
||||
<Box
|
||||
pt={theme.spacing(LAYOUT.SM)}
|
||||
sx={{
|
||||
columnCount: { xs: 1, sm: 2 },
|
||||
columnGap: theme.spacing(LAYOUT.XL),
|
||||
}}
|
||||
>
|
||||
{grouped.map(({ group, cards }) => (
|
||||
<Stack
|
||||
key={group}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
pb={theme.spacing(LAYOUT.MD)}
|
||||
sx={{ breakInside: "avoid" }}
|
||||
>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.disabled}
|
||||
>
|
||||
{t(`pages.dashboard.picker.groups.${group}`, {
|
||||
defaultValue: group,
|
||||
})}
|
||||
</Typography>
|
||||
{cards.map((card) => (
|
||||
<FormControlLabel
|
||||
key={card.id}
|
||||
label={
|
||||
<Typography color={theme.palette.text.primary}>
|
||||
{t(`pages.dashboard.cards.${card.key}.title`)}
|
||||
</Typography>
|
||||
}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={chosen.has(card.id)}
|
||||
onChange={() =>
|
||||
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import { Tooltip } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* One thickness for every bar on the dashboard. Plain and segmented bars sit
|
||||
* side by side across cards, so differing weights read as an inconsistency
|
||||
* rather than as emphasis.
|
||||
*/
|
||||
const BAR_HEIGHT = 8;
|
||||
|
||||
export interface BarSegment {
|
||||
key: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Tooltip text, since the segments themselves carry no labels. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A proportional bar. `value` and `max` share whatever unit the caller uses;
|
||||
* percentages come in as 0-100, not as the API's 0-1 fractions.
|
||||
*/
|
||||
export const CardBar = ({
|
||||
value,
|
||||
max = 100,
|
||||
color,
|
||||
}: {
|
||||
value: number;
|
||||
max?: number;
|
||||
color: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
height={BAR_HEIGHT}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{ overflow: "hidden" }}
|
||||
>
|
||||
<Box
|
||||
width={`${ratio * 100}%`}
|
||||
height="100%"
|
||||
bgcolor={color}
|
||||
sx={{ transition: "width 0.3s ease" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* One bar divided into proportional segments — the shape of a whole split into
|
||||
* parts, where the relative sizes are the point rather than any single value.
|
||||
*
|
||||
* Zero-value segments are dropped so they cannot appear as hairlines, and each
|
||||
* segment carries a tooltip because the bar itself has no room for labels.
|
||||
*/
|
||||
export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
|
||||
const theme = useTheme();
|
||||
const visible = segments.filter((segment) => segment.value > 0);
|
||||
const total = visible.reduce((sum, segment) => sum + segment.value, 0);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
width="100%"
|
||||
height={BAR_HEIGHT}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
gap="1px"
|
||||
sx={{ overflow: "hidden" }}
|
||||
>
|
||||
{total > 0 &&
|
||||
visible.map((segment) => (
|
||||
<Tooltip
|
||||
key={segment.key}
|
||||
title={segment.label}
|
||||
>
|
||||
<Box
|
||||
width={`${(segment.value / total) * 100}%`}
|
||||
height="100%"
|
||||
bgcolor={segment.color}
|
||||
sx={{ transition: "width 0.3s ease" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** A clickable list row. Rows link to the entity they describe. */
|
||||
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...(to ? { component: RouterLink, to } : {})}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
px={theme.spacing(LAYOUT.XS)}
|
||||
py={theme.spacing(LAYOUT.XS)}
|
||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
color="inherit"
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
...(to && {
|
||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** Name plus optional secondary line, truncating rather than wrapping. */
|
||||
export const CardRowLabel = ({
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
primary: string;
|
||||
secondary?: string | null;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
minWidth={0}
|
||||
flex={1}
|
||||
>
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{primary}
|
||||
</Typography>
|
||||
{secondary && (
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
noWrap
|
||||
>
|
||||
{secondary}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** A single large figure with a caption — the shape of the stat cards. */
|
||||
export const CardFigure = ({
|
||||
value,
|
||||
caption,
|
||||
color,
|
||||
}: {
|
||||
value: string;
|
||||
caption?: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xxl}
|
||||
fontWeight={300}
|
||||
color={color ?? theme.palette.text.primary}
|
||||
lineHeight={1.1}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
{caption && <Typography color={theme.palette.text.secondary}>{caption}</Typography>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A label with its value on the right — the shape almost every stat card
|
||||
* repeats. `color` highlights the value alone, leaving the label quiet.
|
||||
*/
|
||||
export const CardStatLine = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
||||
<Typography
|
||||
color={color ?? theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A CardStatLine with a proportional bar beneath it — the ranking and
|
||||
* breakdown row. `max` defaults to a percentage scale; pass the largest value
|
||||
* in the set to make bars relative to each other instead.
|
||||
*
|
||||
* `label` may be a node so callers can put a "N down" flag beside the name.
|
||||
*/
|
||||
export const CardMeterRow = ({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
color,
|
||||
trailing,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: number;
|
||||
max?: number;
|
||||
color: string;
|
||||
trailing?: ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{typeof label === "string" ? (
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{trailing}
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={value}
|
||||
max={max}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const CardSlotContext = createContext<ReactNode>(null);
|
||||
|
||||
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
|
||||
@@ -0,0 +1,219 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
|
||||
|
||||
import type { QueueMetrics } from "@/Types/Queue";
|
||||
import type { Log } from "@/Types/Log";
|
||||
import type { User } from "@/Types/User";
|
||||
|
||||
const MAX_ERROR_ROWS = 5;
|
||||
|
||||
/** Reads as a single reassurance line while nothing is failing. */
|
||||
export const ChecksOnScheduleCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
|
||||
"/queue/metrics",
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const healthy = data?.failingJobs === 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.checksOnSchedule.title")}
|
||||
to="/logs"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{healthy ? (
|
||||
<CardMessage
|
||||
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
|
||||
jobs: data.jobs,
|
||||
workers: data.workers.length,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CardFigure
|
||||
value={String(data.failingJobs)}
|
||||
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
|
||||
color={theme.palette.error.main}
|
||||
/>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
|
||||
<CardRow key={String(job.monitorId)}>
|
||||
<CardRowLabel
|
||||
primary={job.monitorUrl ?? String(job.monitorId)}
|
||||
secondary={job.failReason}
|
||||
/>
|
||||
<Typography
|
||||
color={theme.palette.error.main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{job.failCount}
|
||||
</Typography>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
|
||||
value={String(data.jobs)}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
|
||||
value={String(data.activeJobs)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
|
||||
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
|
||||
* worded "recent" rather than "all".
|
||||
*/
|
||||
export const RecentErrorsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<Log[]>(
|
||||
"/logs",
|
||||
{},
|
||||
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const { errors, warnings, newest } = useMemo(() => {
|
||||
const relevant = (data ?? []).filter(
|
||||
(entry) => entry.level === "error" || entry.level === "warn"
|
||||
);
|
||||
const sorted = [...relevant].sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
return {
|
||||
errors: relevant.filter((entry) => entry.level === "error").length,
|
||||
warnings: relevant.filter((entry) => entry.level === "warn").length,
|
||||
newest: sorted.slice(0, MAX_ERROR_ROWS),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.recentErrors.title")}
|
||||
to="/logs"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
errors > 0 ? (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{newest.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.recentErrors.warnings")}
|
||||
value={String(warnings)}
|
||||
/>
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{newest.map((entry, index) => (
|
||||
<CardRow key={`${entry.timestamp}-${index}`}>
|
||||
<CardRowLabel
|
||||
primary={entry.message}
|
||||
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
|
||||
/>
|
||||
<Typography
|
||||
color={
|
||||
entry.level === "error"
|
||||
? theme.palette.error.main
|
||||
: theme.palette.warning.main
|
||||
}
|
||||
flexShrink={0}
|
||||
>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</Typography>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
/** Never empty — at minimum the current user exists. */
|
||||
export const TeamCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
|
||||
|
||||
const { total, byRole, unverified } = useMemo(() => {
|
||||
const users = data ?? [];
|
||||
const roles = new Map<string, number>();
|
||||
for (const user of users) {
|
||||
for (const role of user.role ?? []) {
|
||||
roles.set(role, (roles.get(role) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: users.length,
|
||||
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
|
||||
unverified: users.filter((user) => !user.isVerified).length,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.team.title")}
|
||||
to="/account/team"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardFigure
|
||||
value={String(total)}
|
||||
caption={t("pages.dashboard.cards.team.members", { count: total })}
|
||||
/>
|
||||
{byRole.map(([role, count]) => (
|
||||
<CardStatLine
|
||||
key={role}
|
||||
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
|
||||
value={String(count)}
|
||||
/>
|
||||
))}
|
||||
{unverified > 0 && (
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.team.unverified")}
|
||||
value={String(unverified)}
|
||||
color={theme.palette.warning.main}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardMeterRow, CardRow } from "../CardPrimitives";
|
||||
|
||||
export interface BreakdownBucket {
|
||||
/** Stable key and displayed label. */
|
||||
key: string;
|
||||
label: string;
|
||||
total: number;
|
||||
down: number;
|
||||
/** Where the row links to, if anywhere. */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "How the fleet splits by X" — one proportional row per bucket, flagging any
|
||||
* bucket with something down. Monitors by type and Monitors by group are the
|
||||
* same card over different keys, so they share this shell.
|
||||
*/
|
||||
export const BreakdownCard = ({
|
||||
titleKey,
|
||||
emptyKey,
|
||||
to,
|
||||
buckets,
|
||||
isLoading,
|
||||
isStale,
|
||||
error,
|
||||
}: {
|
||||
titleKey: string;
|
||||
emptyKey: string;
|
||||
to?: string;
|
||||
buckets: BreakdownBucket[];
|
||||
isLoading?: boolean;
|
||||
isStale?: boolean;
|
||||
error?: unknown;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t(titleKey)}
|
||||
to={to}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
isStale={isStale}
|
||||
>
|
||||
{buckets.length === 0 ? (
|
||||
<CardMessage text={t(emptyKey)} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{buckets.map((bucket) => (
|
||||
<CardRow
|
||||
key={bucket.key}
|
||||
to={bucket.to}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={bucket.label}
|
||||
value={bucket.total}
|
||||
max={max}
|
||||
color={
|
||||
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
|
||||
}
|
||||
trailing={
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{bucket.down > 0 && (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
||||
count: bucket.down,
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{bucket.total}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import { latestCheck } from "../../checks";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
// <60% fine, 60-85% warm, above that hot.
|
||||
const WARM = 60;
|
||||
const HOT = 85;
|
||||
|
||||
interface ServerUsage {
|
||||
monitor: Monitor;
|
||||
cpu: number | null;
|
||||
memory: number | null;
|
||||
disk: number | null;
|
||||
peak: number;
|
||||
}
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | null }) => {
|
||||
const theme = useTheme();
|
||||
const color =
|
||||
value === null
|
||||
? theme.palette.text.secondary
|
||||
: value > HOT
|
||||
? theme.palette.error.main
|
||||
: value > WARM
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.primary.main;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
||||
<Typography color={color}>
|
||||
{/* A down server reports nothing — show a dash, never 0%. */}
|
||||
{value === null ? "—" : `${value.toFixed(1)}%`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CardBar
|
||||
value={value ?? 0}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const BusiestServersCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const servers = useMemo<ServerUsage[]>(() => {
|
||||
return (data?.monitors ?? [])
|
||||
.filter((monitor) => monitor.type === "hardware")
|
||||
.map((monitor) => {
|
||||
const latest = latestCheck(monitor);
|
||||
// snake_case here, unlike camelCase in HardwareCheckStats, and
|
||||
// these are 0-1 fractions, so scale to percent.
|
||||
const toPercent = (value: number | undefined) =>
|
||||
typeof value === "number" ? value * 100 : null;
|
||||
const cpu = toPercent(latest?.cpu?.usage_percent);
|
||||
const memory = toPercent(latest?.memory?.usage_percent);
|
||||
const disks = (latest?.disk ?? [])
|
||||
.map((entry) => entry.usage_percent)
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
|
||||
return {
|
||||
monitor,
|
||||
cpu,
|
||||
memory,
|
||||
disk,
|
||||
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.peak - a.peak);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.busiestServers.title")}
|
||||
to="/infrastructure"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{servers.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{servers.map(({ monitor, cpu, memory, disk }) => (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/infrastructure/${monitor.id}`}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<CardRowLabel primary={monitor.name} />
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
<Metric
|
||||
label={t("pages.dashboard.cards.busiestServers.cpu")}
|
||||
value={cpu}
|
||||
/>
|
||||
<Metric
|
||||
label={t("pages.dashboard.cards.busiestServers.memory")}
|
||||
value={memory}
|
||||
/>
|
||||
<Metric
|
||||
label={t("pages.dashboard.cards.busiestServers.disk")}
|
||||
value={disk}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard } from "../DashboardCard";
|
||||
import { CardBar, CardStatLine } from "../CardPrimitives";
|
||||
|
||||
import { REFRESH_INTERVAL_MS } from "../../cards";
|
||||
|
||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
|
||||
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
|
||||
// heap warm past 80% of the V8 limit.
|
||||
const EVENT_LOOP_WARM_MS = 50;
|
||||
const EVENT_LOOP_HOT_MS = 200;
|
||||
const HEAP_WARM_RATIO = 0.8;
|
||||
|
||||
/**
|
||||
* The one card that is never empty — it reports on Checkmate itself, so it
|
||||
* works on a fresh install with zero monitors. Admin only.
|
||||
*/
|
||||
export const CheckmateServerCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<Diagnostics>(
|
||||
"/diagnostic/system",
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
||||
);
|
||||
|
||||
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
|
||||
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
|
||||
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
|
||||
const heapColor =
|
||||
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
|
||||
|
||||
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
|
||||
const eventLoopColor =
|
||||
eventLoopMs > EVENT_LOOP_HOT_MS
|
||||
? theme.palette.error.main
|
||||
: eventLoopMs > EVENT_LOOP_WARM_MS
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.text.primary;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.checkmateServer.title")}
|
||||
to="/logs"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.memory")}
|
||||
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
|
||||
heapLimit / BYTES_PER_MB
|
||||
)} MB`}
|
||||
color={heapColor}
|
||||
/>
|
||||
<CardBar
|
||||
value={heapRatio * 100}
|
||||
color={heapColor}
|
||||
/>
|
||||
</Stack>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
|
||||
value={`${eventLoopMs.toFixed(1)} ms`}
|
||||
color={eventLoopColor}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.uptime")}
|
||||
value={formatDuration(data.uptimeMs)}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.checkmateServer.database")}
|
||||
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
|
||||
reads: data.mongoStats.readsPerSecond.toFixed(1),
|
||||
writes: data.mongoStats.writesPerSecond.toFixed(1),
|
||||
})}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
|
||||
import { formatStatusCode } from "@/Utils/statusCode";
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardRow } from "../CardPrimitives";
|
||||
import { Sparkline } from "../Sparkline";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import { latestCheck } from "../../checks";
|
||||
|
||||
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
||||
|
||||
const SPARKLINE_WIDTH = 80;
|
||||
|
||||
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
|
||||
down: 1,
|
||||
breached: 2,
|
||||
};
|
||||
|
||||
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
|
||||
|
||||
/**
|
||||
* How long the monitor has been down, from the oldest consecutive failing
|
||||
* check. recentChecks is oldest-first, so the run is walked back from the end.
|
||||
*/
|
||||
const downSince = (monitor: Monitor): string | null => {
|
||||
const checks = monitor.recentChecks ?? [];
|
||||
let oldestFailing: string | null = null;
|
||||
for (let index = checks.length - 1; index >= 0; index -= 1) {
|
||||
if (checks[index].status !== false) {
|
||||
break;
|
||||
}
|
||||
oldestFailing = checks[index].createdAt;
|
||||
}
|
||||
return oldestFailing;
|
||||
};
|
||||
|
||||
export const CurrentlyDownCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const problems = useMemo(() => {
|
||||
const monitors = data?.monitors ?? [];
|
||||
return monitors
|
||||
.filter(isProblem)
|
||||
.map((monitor) => ({ monitor, since: downSince(monitor) }))
|
||||
.sort((a, b) => {
|
||||
const rank =
|
||||
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
|
||||
if (rank !== 0) {
|
||||
return rank;
|
||||
}
|
||||
// Tie-break on longest downtime: the older timestamp ranks first.
|
||||
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.currentlyDown.title")}
|
||||
to="/incidents"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
problems.length > 0 ? (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.currentlyDown.count", {
|
||||
count: problems.length,
|
||||
})}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{problems.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{problems.map(({ monitor, since }) => (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<Dot
|
||||
color={getStatusColor(monitor.status, theme)}
|
||||
size="md"
|
||||
/>
|
||||
<Stack
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
gap={theme.spacing(LAYOUT.XXS)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography
|
||||
color={theme.palette.text.primary}
|
||||
noWrap
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
{since && (
|
||||
<Typography
|
||||
color={theme.palette.error.main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{formatDuration(Date.now() - new Date(since).getTime())}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
{/* Not StatusCodeLabel: its Tooltip is focusable, and a
|
||||
focusable element inside the row's link is unreachable
|
||||
by keyboard. */}
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
noWrap
|
||||
>
|
||||
{monitor.type} ·{" "}
|
||||
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
||||
</Typography>
|
||||
<Box
|
||||
width={SPARKLINE_WIDTH}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Sparkline
|
||||
checks={monitor.recentChecks ?? []}
|
||||
color={theme.palette.error.main}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardBar, CardFigure } from "../CardPrimitives";
|
||||
|
||||
import type { ChecksSummary } from "@/Types/Check";
|
||||
|
||||
// Day only. `dateRange=week` returns figures identical to `day`, so a range
|
||||
// selector here would silently lie.
|
||||
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
|
||||
|
||||
export const FailedChecksCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<ChecksSummary>(SUMMARY_URL);
|
||||
|
||||
const total = data?.totalChecks ?? 0;
|
||||
const failed = data?.downChecks ?? 0;
|
||||
const percentage = total > 0 ? (failed / total) * 100 : 0;
|
||||
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.failedChecks.title")}
|
||||
to="/checks"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{total === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardFigure
|
||||
value={`${percentage.toFixed(1)}%`}
|
||||
caption={t("pages.dashboard.cards.failedChecks.ratio", {
|
||||
failed: failed.toLocaleString(),
|
||||
total: total.toLocaleString(),
|
||||
})}
|
||||
color={color}
|
||||
/>
|
||||
<CardBar
|
||||
value={percentage}
|
||||
color={color}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { getStatusColor } from "@/Utils/MonitorUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardSegmentedBar, type BarSegment } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
|
||||
|
||||
// Every state the summary reports, ordered worst first so the bar reads
|
||||
// problems-to-healthy left to right and the chips below list what is wrong
|
||||
// before what is merely idle. `up` is handled separately as the headline.
|
||||
const STATES: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
|
||||
{ key: "down", status: "down", field: "downMonitors" },
|
||||
{ key: "breached", status: "breached", field: "breachedMonitors" },
|
||||
{ key: "initializing", status: "initializing", field: "initializingMonitors" },
|
||||
{ key: "maintenance", status: "maintenance", field: "maintenanceMonitors" },
|
||||
{ key: "paused", status: "paused", field: "pausedMonitors" },
|
||||
];
|
||||
|
||||
export const MonitorStatusCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
const summary = data?.summary ?? null;
|
||||
|
||||
// Only states that actually have monitors are shown. Padding the card with
|
||||
// zeros says nothing, and a healthy fleet should read as calm rather than as
|
||||
// a row of empty counters.
|
||||
const present = summary
|
||||
? STATES.map((state) => ({ ...state, count: summary[state.field] })).filter(
|
||||
(state) => state.count > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
const segments: BarSegment[] = summary
|
||||
? [
|
||||
{
|
||||
key: "up",
|
||||
value: summary.upMonitors,
|
||||
color: getStatusColor("up", theme),
|
||||
label: `${t("pages.common.monitors.status.up")} — ${summary.upMonitors}`,
|
||||
},
|
||||
...present.map((state) => ({
|
||||
key: state.key,
|
||||
value: state.count,
|
||||
color: getStatusColor(state.status, theme),
|
||||
label: `${t(`pages.common.monitors.status.${state.key}`)} — ${state.count}`,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.monitorStatus.title")}
|
||||
to="/uptime"
|
||||
isLoading={isLoading && !summary}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{!summary || summary.totalMonitors === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.monitorStatus.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xxl}
|
||||
fontWeight={300}
|
||||
color={theme.palette.text.primary}
|
||||
lineHeight={1.1}
|
||||
>
|
||||
{summary.upMonitors}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.dashboard.cards.monitorStatus.upOfTotal", {
|
||||
total: summary.totalMonitors,
|
||||
})}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CardSegmentedBar segments={segments} />
|
||||
</Stack>
|
||||
{present.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="wrap"
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
>
|
||||
{present.map((state) => (
|
||||
<Stack
|
||||
key={state.key}
|
||||
component={RouterLink}
|
||||
// Plain /uptime: the monitors list does not read query
|
||||
// params, so a ?status= filter would be silently dropped
|
||||
// and the link would lie about where it goes.
|
||||
to="/uptime"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
px={theme.spacing(LAYOUT.XS)}
|
||||
py={theme.spacing(LAYOUT.XXS)}
|
||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
||||
}}
|
||||
>
|
||||
<Dot
|
||||
color={getStatusColor(state.status, theme)}
|
||||
size="md"
|
||||
/>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t(`pages.common.monitors.status.${state.key}`)}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.primary}>
|
||||
{state.count}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
export const MonitorsByGroupCard = () => {
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
// `group` is a plain string | null on the monitor — there is no groups
|
||||
// endpoint, so the set is whatever monitors actually declare.
|
||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||
const byGroup = new Map<string, BreakdownBucket>();
|
||||
for (const monitor of data?.monitors ?? []) {
|
||||
if (!monitor.group) {
|
||||
continue;
|
||||
}
|
||||
const bucket = byGroup.get(monitor.group) ?? {
|
||||
key: monitor.group,
|
||||
label: monitor.group,
|
||||
total: 0,
|
||||
down: 0,
|
||||
};
|
||||
bucket.total += 1;
|
||||
if (monitor.status === "down" || monitor.status === "breached") {
|
||||
bucket.down += 1;
|
||||
}
|
||||
byGroup.set(monitor.group, bucket);
|
||||
}
|
||||
return [...byGroup.values()].sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<BreakdownCard
|
||||
titleKey="pages.dashboard.cards.monitorsByGroup.title"
|
||||
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
|
||||
to="/uptime"
|
||||
buckets={buckets}
|
||||
isLoading={isLoading && !data}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getMonitorPath } from "@/Utils/MonitorUtils";
|
||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { MonitorType } from "@/Types/Monitor";
|
||||
|
||||
export const MonitorsByTypeCard = () => {
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
||||
const byType = new Map<MonitorType, BreakdownBucket>();
|
||||
for (const monitor of data?.monitors ?? []) {
|
||||
const bucket = byType.get(monitor.type) ?? {
|
||||
key: monitor.type,
|
||||
label: monitor.type,
|
||||
total: 0,
|
||||
down: 0,
|
||||
to: `/${getMonitorPath(monitor.type)}`,
|
||||
};
|
||||
bucket.total += 1;
|
||||
if (monitor.status === "down" || monitor.status === "breached") {
|
||||
bucket.down += 1;
|
||||
}
|
||||
byType.set(monitor.type, bucket);
|
||||
}
|
||||
return [...byType.values()].sort((a, b) => b.total - a.total);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<BreakdownCard
|
||||
titleKey="pages.dashboard.cards.monitorsByType.title"
|
||||
emptyKey="pages.dashboard.cards.monitorsByType.empty"
|
||||
to="/uptime"
|
||||
buckets={buckets}
|
||||
isLoading={isLoading && !data}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
import { useMonitors } from "../../useDashboardData";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
const TOP_N = 5;
|
||||
// A ranking of one is not a ranking.
|
||||
const MIN_RANKABLE = 2;
|
||||
|
||||
const averageResponseTime = (monitor: Monitor): number | null => {
|
||||
const times = (monitor.recentChecks ?? [])
|
||||
.map((check) => check.responseTime)
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
if (times.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return times.reduce((sum, value) => sum + value, 0) / times.length;
|
||||
};
|
||||
|
||||
/** Header reads "top 5 of 12" so a truncated ranking says so. */
|
||||
const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.dashboard.cards.ranking.topOf", { shown, total })}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export const SlowestMonitorsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
return (data?.monitors ?? [])
|
||||
.filter((monitor) => monitor.status !== "paused")
|
||||
.map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
|
||||
.filter(
|
||||
(entry): entry is { monitor: Monitor; average: number } => entry.average !== null
|
||||
)
|
||||
.sort((a, b) => b.average - a.average);
|
||||
}, [data]);
|
||||
|
||||
const slowest = ranked[0]?.average ?? 0;
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.slowestMonitors.title")}
|
||||
to="/uptime"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
ranked.length >= MIN_RANKABLE ? (
|
||||
<RankingCount
|
||||
shown={Math.min(TOP_N, ranked.length)}
|
||||
total={ranked.length}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{ranked.length < MIN_RANKABLE ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{ranked.slice(0, TOP_N).map(({ monitor, average }) => (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={<CardRowLabel primary={monitor.name} />}
|
||||
value={average}
|
||||
max={slowest}
|
||||
color={theme.palette.primary.main}
|
||||
trailing={
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
{Math.round(average)} ms
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const LowestUptimeCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useMonitors();
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
return (
|
||||
(data?.monitors ?? [])
|
||||
// Paused monitors keep whatever uptime they had, or zero — a paused
|
||||
// ping monitor reads 0% and would rank beside a genuinely down one.
|
||||
// Neither is a live problem, so neither belongs in a worst-uptime list.
|
||||
.filter((monitor) => monitor.status !== "paused")
|
||||
.filter(
|
||||
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||
typeof monitor.uptimePercentage === "number"
|
||||
)
|
||||
.sort((a, b) => a.uptimePercentage - b.uptimePercentage)
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.lowestUptime.title")}
|
||||
to="/uptime"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
ranked.length >= MIN_RANKABLE ? (
|
||||
<RankingCount
|
||||
shown={Math.min(TOP_N, ranked.length)}
|
||||
total={ranked.length}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{ranked.length < MIN_RANKABLE ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{ranked.slice(0, TOP_N).map((monitor) => {
|
||||
// uptimePercentage is a 0-1 fraction, not 0-100.
|
||||
const percentage = monitor.uptimePercentage * 100;
|
||||
const palette = getUptimePercentageColor(monitor.uptimePercentage);
|
||||
return (
|
||||
<CardRow
|
||||
key={monitor.id}
|
||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
||||
>
|
||||
<CardMeterRow
|
||||
label={<CardRowLabel primary={monitor.name} />}
|
||||
// The card ranks worst uptime first, so the bar shows the
|
||||
// shortfall - the worst monitor gets the longest bar.
|
||||
value={100 - percentage}
|
||||
color={theme.palette[palette].main}
|
||||
trailing={
|
||||
<Typography
|
||||
color={theme.palette[palette].main}
|
||||
flexShrink={0}
|
||||
>
|
||||
{percentage.toFixed(1)}%
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</CardRow>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import { CardRow, CardRowLabel } from "../CardPrimitives";
|
||||
|
||||
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
|
||||
|
||||
const MAX_ROWS = 5;
|
||||
|
||||
/** Open incidents have no end time; closed ones report how long they ran. */
|
||||
const incidentDuration = (incident: IncidentSummaryItem): string | null => {
|
||||
if (!incident.endTime) {
|
||||
return null;
|
||||
}
|
||||
const ms =
|
||||
new Date(incident.endTime).getTime() - new Date(incident.startTime).getTime();
|
||||
return ms > 0 ? formatDuration(ms) : null;
|
||||
};
|
||||
|
||||
export const RecentIncidentsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
||||
"/incidents/team/summary"
|
||||
);
|
||||
|
||||
const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.recentIncidents.title")}
|
||||
to="/incidents"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
action={
|
||||
data && data.totalActive > 0 ? (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.dashboard.cards.recentIncidents.active", {
|
||||
count: data.totalActive,
|
||||
})}
|
||||
</Typography>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{incidents.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.recentIncidents.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{incidents.map((incident) => {
|
||||
const isOpen = incident.status === true;
|
||||
const duration = incidentDuration(incident);
|
||||
return (
|
||||
<CardRow
|
||||
key={incident.id}
|
||||
to="/incidents"
|
||||
>
|
||||
<Dot
|
||||
color={isOpen ? theme.palette.error.light : theme.palette.success.light}
|
||||
size="md"
|
||||
/>
|
||||
<CardRowLabel
|
||||
primary={
|
||||
incident.monitorName ??
|
||||
t("pages.dashboard.cards.recentIncidents.unknownMonitor")
|
||||
}
|
||||
secondary={formatTimestamp(incident.startTime)}
|
||||
/>
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{isOpen
|
||||
? t("pages.dashboard.cards.recentIncidents.ongoing")
|
||||
: (duration ?? "—")}
|
||||
</Typography>
|
||||
{incident.resolutionType === "manual" && (
|
||||
<Typography
|
||||
color={theme.palette.warning.main}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("pages.dashboard.cards.recentIncidents.manual")}
|
||||
</Typography>
|
||||
)}
|
||||
</CardRow>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
||||
import {
|
||||
CardFigure,
|
||||
CardRow,
|
||||
CardRowLabel,
|
||||
CardSegmentedBar,
|
||||
CardStatLine,
|
||||
} from "../CardPrimitives";
|
||||
|
||||
import type { IncidentSummary } from "@/Types/Incident";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
/** Free when Recent incidents is present — same summary call, shared by SWR. */
|
||||
export const IncidentStatsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
||||
"/incidents/team/summary"
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.incidentStats.title")}
|
||||
to="/incidents"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{data && (
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{/* Open now leads, paired with the all-time total for context: two
|
||||
open means something different against 12 incidents than 183. */}
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(LAYOUT.XXL)}
|
||||
>
|
||||
<CardFigure
|
||||
value={String(data.totalActive)}
|
||||
caption={t("pages.dashboard.cards.incidentStats.openNow")}
|
||||
color={
|
||||
data.totalActive > 0
|
||||
? theme.palette.error.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
/>
|
||||
<CardFigure
|
||||
value={data.total.toLocaleString()}
|
||||
caption={t("pages.dashboard.cards.incidentStats.total")}
|
||||
/>
|
||||
</Stack>
|
||||
{/* How much of the fleet heals itself — the automatic/manual split
|
||||
is already computed server-side and said nothing on the card
|
||||
before. A high manual share means people are doing the work. */}
|
||||
{data.totalAutomaticResolutions + data.totalManualResolutions > 0 && (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<CardSegmentedBar
|
||||
segments={[
|
||||
{
|
||||
key: "automatic",
|
||||
value: data.totalAutomaticResolutions,
|
||||
color: theme.palette.success.main,
|
||||
label: `${t("pages.dashboard.cards.incidentStats.automatic")} — ${data.totalAutomaticResolutions}`,
|
||||
},
|
||||
{
|
||||
key: "manual",
|
||||
value: data.totalManualResolutions,
|
||||
color: theme.palette.warning.main,
|
||||
label: `${t("pages.dashboard.cards.incidentStats.manual")} — ${data.totalManualResolutions}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.resolved")}
|
||||
value={t("pages.dashboard.cards.incidentStats.autoManual", {
|
||||
automatic: data.totalAutomaticResolutions,
|
||||
manual: data.totalManualResolutions,
|
||||
})}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
|
||||
value={t("pages.dashboard.cards.incidentStats.hours", {
|
||||
hours: data.avgResolutionTimeHours.toFixed(1),
|
||||
})}
|
||||
/>
|
||||
{data.topMonitor?.monitorName && (
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
|
||||
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotificationChannelsCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } =
|
||||
useGet<Notification[]>("/notifications/team");
|
||||
|
||||
// The response also carries accessToken and accountSid. Only `type` is read
|
||||
// here — credential fields must never reach the DOM.
|
||||
const byType = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const notification of data ?? []) {
|
||||
counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.notificationChannels.title")}
|
||||
to="/notifications"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{byType.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.notificationChannels.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{byType.map(([type, count]) => (
|
||||
<CardStatLine
|
||||
key={type}
|
||||
label={type}
|
||||
value={String(count)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const MaintenanceCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } = useGet<{
|
||||
maintenanceWindows: MaintenanceWindow[];
|
||||
maintenanceWindowCount: number;
|
||||
}>("/maintenance-window/team?page=0&rowsPerPage=0");
|
||||
|
||||
// `active` is a user on/off flag, not "running now" — compare the window
|
||||
// client-side. repeat > 0 means recurring, so `end` may sit in the past for
|
||||
// a window that is currently live.
|
||||
const { running, next } = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
|
||||
const live = windows.filter((entry) => {
|
||||
const start = new Date(entry.start).getTime();
|
||||
const end = new Date(entry.end).getTime();
|
||||
return start <= now && (now <= end || entry.repeat > 0);
|
||||
});
|
||||
const upcoming = windows
|
||||
.filter((entry) => new Date(entry.start).getTime() > now)
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
return { running: live.length, next: upcoming[0] ?? null };
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.maintenance.title")}
|
||||
to="/maintenance"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{!data || data.maintenanceWindows.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.maintenance.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
<CardFigure
|
||||
value={String(running)}
|
||||
caption={t("pages.dashboard.cards.maintenance.runningNow")}
|
||||
/>
|
||||
{next && (
|
||||
<CardStatLine
|
||||
label={t("pages.dashboard.cards.maintenance.next")}
|
||||
value={formatTimestamp(next.start)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusPagesCard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isValidating, error } =
|
||||
useGet<StatusPage[]>("/status-page/team");
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
title={t("pages.dashboard.cards.statusPages.title")}
|
||||
to="/status"
|
||||
isLoading={isLoading && !data}
|
||||
error={error}
|
||||
isStale={isValidating && Boolean(data)}
|
||||
>
|
||||
{!data || data.length === 0 ? (
|
||||
<CardMessage text={t("pages.dashboard.cards.statusPages.empty")} />
|
||||
) : (
|
||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
||||
{data.map((page) => (
|
||||
<CardRow
|
||||
key={page.id}
|
||||
to={`/status/${page.url}`}
|
||||
>
|
||||
<Dot
|
||||
color={
|
||||
page.isPublished
|
||||
? theme.palette.success.light
|
||||
: theme.palette.text.secondary
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
<CardRowLabel
|
||||
primary={page.companyName}
|
||||
secondary={t("pages.dashboard.cards.statusPages.monitorCount", {
|
||||
count: page.monitors.length,
|
||||
})}
|
||||
/>
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
flexShrink={0}
|
||||
>
|
||||
{page.isPublished
|
||||
? t("pages.dashboard.cards.statusPages.published")
|
||||
: t("pages.dashboard.cards.statusPages.draft")}
|
||||
</Typography>
|
||||
</CardRow>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useCardSlot } from "./CardSlotContext";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Control height plus the header's vertical padding, so a card with no action
|
||||
// still lines up with one that has a button in its header.
|
||||
const HEADER_MIN_HEIGHT = 44;
|
||||
const SKELETON_ROW_HEIGHT = 20;
|
||||
|
||||
interface DashboardCardProps {
|
||||
title: string;
|
||||
/** Where the title links to — the page this card belongs to. */
|
||||
to?: string;
|
||||
/** Rendered at the right of the header: a count, an average, a filter. */
|
||||
action?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
error?: unknown;
|
||||
/** Dimmed rather than blanked while a refresh is in flight over stale data. */
|
||||
isStale?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DashboardCard = ({
|
||||
title,
|
||||
to,
|
||||
action,
|
||||
isLoading,
|
||||
error,
|
||||
isStale,
|
||||
children,
|
||||
}: DashboardCardProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const slot = useCardSlot();
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
height="100%"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
"&:hover .dashboard-card-slot": {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
px={theme.spacing(LAYOUT.MD)}
|
||||
py={theme.spacing(LAYOUT.SM)}
|
||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
||||
minHeight={HEADER_MIN_HEIGHT}
|
||||
>
|
||||
<Typography
|
||||
{...(to ? { component: RouterLink, to } : {})}
|
||||
fontWeight={500}
|
||||
color={theme.palette.text.primary}
|
||||
sx={{
|
||||
textDecoration: "none",
|
||||
...(to && {
|
||||
"&:hover": { color: theme.palette.primary.main },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
>
|
||||
{isStale && (
|
||||
<CircularProgress
|
||||
size={16}
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
{action && <Box>{action}</Box>}
|
||||
{slot && (
|
||||
<Box
|
||||
className="dashboard-card-slot"
|
||||
sx={{
|
||||
opacity: 0.3,
|
||||
transition: "opacity 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{slot}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
flex={1}
|
||||
p={theme.spacing(LAYOUT.MD)}
|
||||
sx={{
|
||||
...(isStale && { opacity: 0.5, transition: "opacity 0.2s ease" }),
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : error ? (
|
||||
<CardMessage text={t("pages.dashboard.card.error")} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Box>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
/** Placeholder rows, sized to the row list most cards render. */
|
||||
export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
variant="rounded"
|
||||
height={SKELETON_ROW_HEIGHT}
|
||||
width={index === rows - 1 ? "60%" : "100%"}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** The quiet one-line state a healthy card collapses to. */
|
||||
export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
>
|
||||
<Typography color={theme.palette.text.secondary}>{text}</Typography>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
|
||||
const VIEWBOX_WIDTH = 100;
|
||||
// The svg renders at its viewBox height, so one constant serves both.
|
||||
const VIEWBOX_HEIGHT = 24;
|
||||
// Matches the 1.5 stroke the shared Icon component uses.
|
||||
const STROKE_WIDTH = 1.5;
|
||||
|
||||
/**
|
||||
* Response times as a bare polyline. Deliberately axis-free and label-free —
|
||||
* at this size the shape is the only readable signal.
|
||||
*
|
||||
* `checks` arrives oldest-first, which is already left-to-right reading order.
|
||||
*/
|
||||
export const Sparkline = ({
|
||||
checks,
|
||||
color,
|
||||
}: {
|
||||
checks: CheckSnapshot[];
|
||||
color: string;
|
||||
}) => {
|
||||
const points = useMemo(() => {
|
||||
const values = checks
|
||||
.map((check) => check.responseTime)
|
||||
.filter((value): value is number => typeof value === "number");
|
||||
|
||||
if (values.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const span = max - min || 1;
|
||||
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * VIEWBOX_WIDTH;
|
||||
const y = VIEWBOX_HEIGHT - ((value - min) / span) * VIEWBOX_HEIGHT;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}, [checks]);
|
||||
|
||||
if (!points) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="svg"
|
||||
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
width="100%"
|
||||
height={VIEWBOX_HEIGHT}
|
||||
display="block"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// The 12-column grid the dashboard lays cards out on.
|
||||
//
|
||||
// Every card is currently half-width, so they pair up down the page and the
|
||||
// grid reads as one system. Full width stays in the type for a card whose
|
||||
// content genuinely needs it. Width is declared per card type in code and is
|
||||
// never a user setting.
|
||||
export const CardWidths = [6, 12] as const;
|
||||
export type CardWidth = (typeof CardWidths)[number];
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
|
||||
// Picker groups mirror the sidebar sections: you look for a card where you
|
||||
// look for the feature it belongs to.
|
||||
export const CardGroups = [
|
||||
"uptime",
|
||||
"infrastructure",
|
||||
"incidents",
|
||||
"notifications",
|
||||
"maintenance",
|
||||
"statusPages",
|
||||
"checks",
|
||||
"logs",
|
||||
"settings",
|
||||
] as const;
|
||||
export type CardGroup = (typeof CardGroups)[number];
|
||||
|
||||
// The Containers card (CARD-SPECS.md #11) is deliberately absent: it needs
|
||||
// `containerSummary` on the check snapshot, which arrives with the Docker
|
||||
// monitoring work. Add it here once that lands on develop.
|
||||
export const CardIds = [
|
||||
"monitorStatus",
|
||||
"currentlyDown",
|
||||
"slowestMonitors",
|
||||
"lowestUptime",
|
||||
"monitorsByType",
|
||||
"monitorsByGroup",
|
||||
"recentIncidents",
|
||||
"incidentStats",
|
||||
"notificationChannels",
|
||||
"maintenance",
|
||||
"statusPages",
|
||||
"failedChecks",
|
||||
"busiestServers",
|
||||
"checkmateServer",
|
||||
"checksOnSchedule",
|
||||
"recentErrors",
|
||||
"team",
|
||||
] as const;
|
||||
export type CardId = (typeof CardIds)[number];
|
||||
|
||||
export interface CardDefinition {
|
||||
id: CardId;
|
||||
/** i18n key fragment under pages.dashboard.cards.<key>. */
|
||||
key: string;
|
||||
group: CardGroup;
|
||||
width: CardWidth;
|
||||
/** Hidden from the page and absent from the picker for non-admins. */
|
||||
adminOnly?: boolean;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
// The cards a fresh dashboard starts with. Chosen so a brand new install still
|
||||
// shows something real: Checkmate server works with zero monitors.
|
||||
// Ordered so the declared widths pair into full rows: a card left alone in a
|
||||
// row is stretched to fill it, so 8- and 4-wide cards are kept adjacent.
|
||||
export const DEFAULT_CARD_IDS: readonly CardId[] = [
|
||||
"monitorStatus",
|
||||
"currentlyDown",
|
||||
"failedChecks",
|
||||
"monitorsByType",
|
||||
"recentIncidents",
|
||||
"checkmateServer",
|
||||
];
|
||||
|
||||
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
|
||||
|
||||
/**
|
||||
* Poll rates, in ms. Monitor and server figures move continuously; the log ring
|
||||
* buffer rotates fast but is expensive to re-read, so it is polled slowly.
|
||||
*/
|
||||
export const REFRESH_INTERVAL_MS = 30000;
|
||||
export const SLOW_REFRESH_INTERVAL_MS = 60000;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
/**
|
||||
* `recentChecks` is oldest-first — the server appends each snapshot with
|
||||
* `$push … $slice: -N` — so the newest retained check is the last element.
|
||||
*/
|
||||
export const latestCheck = (monitor: Monitor) =>
|
||||
monitor.recentChecks?.[monitor.recentChecks.length - 1];
|
||||
@@ -0,0 +1,86 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { CardGrid } from "./Components/CardGrid";
|
||||
import { CardPicker } from "./Components/CardPicker";
|
||||
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
|
||||
import { useCardSelection } from "./useCardSelection";
|
||||
|
||||
const Dashboard = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const monitorsRequest = useMonitorsRequest();
|
||||
const {
|
||||
cards,
|
||||
available,
|
||||
selected,
|
||||
addCard,
|
||||
removeCard,
|
||||
reorderCards,
|
||||
resetCards,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
allSelected,
|
||||
} = useCardSelection();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<MonitorsContext.Provider value={monitorsRequest}>
|
||||
<BasePage headerKey="dashboard">
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => setIsPickerOpen(true)}
|
||||
startIcon={
|
||||
<Icon
|
||||
icon={LayoutGrid}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("pages.dashboard.picker.trigger")}
|
||||
</Button>
|
||||
</Stack>
|
||||
{cards.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("pages.dashboard.empty.title")}
|
||||
description={t("pages.dashboard.empty.description")}
|
||||
actionText={t("pages.dashboard.picker.trigger")}
|
||||
onAction={() => setIsPickerOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<CardGrid
|
||||
cards={cards}
|
||||
onReorder={reorderCards}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<CardPicker
|
||||
open={isPickerOpen}
|
||||
available={available}
|
||||
selected={selected}
|
||||
allSelected={allSelected}
|
||||
onAdd={addCard}
|
||||
onRemove={removeCard}
|
||||
onSelectAll={selectAll}
|
||||
onDeselectAll={deselectAll}
|
||||
onReset={resetCards}
|
||||
onClose={() => setIsPickerOpen(false)}
|
||||
/>
|
||||
</BasePage>
|
||||
</MonitorsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CardDefinition } from "./cards";
|
||||
|
||||
export interface PlacedCard {
|
||||
card: CardDefinition;
|
||||
/** Columns rendered — currently always the declared width. */
|
||||
renderedWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs cards into rows of GRID_COLUMNS.
|
||||
*
|
||||
* With a single body width this is a straight pass-through: two halves fill a
|
||||
* row exactly, so no card is ever stretched to close a gap.
|
||||
*
|
||||
* An odd number of cards leaves the last one alone on its row. It keeps its
|
||||
* declared width rather than being promoted to full: widening one card to fill
|
||||
* the row makes it look more important than it is, and an odd count is a
|
||||
* property of the selection, not something the layout should hide.
|
||||
*/
|
||||
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] =>
|
||||
cards.map((card) => ({ card, renderedWidth: card.width }));
|
||||
@@ -0,0 +1,161 @@
|
||||
import { MonitorStatusCard } from "./Components/Cards/MonitorStatusCard";
|
||||
import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
|
||||
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
|
||||
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
|
||||
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
|
||||
import { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
|
||||
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
|
||||
import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
|
||||
import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
|
||||
import {
|
||||
IncidentStatsCard,
|
||||
NotificationChannelsCard,
|
||||
MaintenanceCard,
|
||||
StatusPagesCard,
|
||||
} from "./Components/Cards/SummaryCards";
|
||||
import {
|
||||
ChecksOnScheduleCard,
|
||||
RecentErrorsCard,
|
||||
TeamCard,
|
||||
} from "./Components/Cards/AdminCards";
|
||||
|
||||
import type { CardDefinition, CardId } from "./cards";
|
||||
|
||||
// Declared width is the minimum number of columns a card needs. The grid may
|
||||
// stretch the last card of a short row; nothing here is a user setting.
|
||||
//
|
||||
// Order is the render order, and the order the picker lists them in.
|
||||
//
|
||||
// Every card is half-width and pairs up down the page. The grid promotes a
|
||||
// lone trailing card to full width so the page does not end on a ragged half.
|
||||
export const CARD_REGISTRY: CardDefinition[] = [
|
||||
{
|
||||
id: "monitorStatus",
|
||||
key: "monitorStatus",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: MonitorStatusCard,
|
||||
},
|
||||
{
|
||||
id: "currentlyDown",
|
||||
key: "currentlyDown",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: CurrentlyDownCard,
|
||||
},
|
||||
{
|
||||
id: "failedChecks",
|
||||
key: "failedChecks",
|
||||
group: "checks",
|
||||
width: 6,
|
||||
component: FailedChecksCard,
|
||||
},
|
||||
{
|
||||
id: "slowestMonitors",
|
||||
key: "slowestMonitors",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: SlowestMonitorsCard,
|
||||
},
|
||||
{
|
||||
id: "lowestUptime",
|
||||
key: "lowestUptime",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: LowestUptimeCard,
|
||||
},
|
||||
{
|
||||
id: "monitorsByType",
|
||||
key: "monitorsByType",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: MonitorsByTypeCard,
|
||||
},
|
||||
{
|
||||
id: "monitorsByGroup",
|
||||
key: "monitorsByGroup",
|
||||
group: "uptime",
|
||||
width: 6,
|
||||
component: MonitorsByGroupCard,
|
||||
},
|
||||
{
|
||||
id: "busiestServers",
|
||||
key: "busiestServers",
|
||||
group: "infrastructure",
|
||||
width: 6,
|
||||
component: BusiestServersCard,
|
||||
},
|
||||
{
|
||||
id: "recentIncidents",
|
||||
key: "recentIncidents",
|
||||
group: "incidents",
|
||||
width: 6,
|
||||
component: RecentIncidentsCard,
|
||||
},
|
||||
{
|
||||
id: "incidentStats",
|
||||
key: "incidentStats",
|
||||
group: "incidents",
|
||||
width: 6,
|
||||
component: IncidentStatsCard,
|
||||
},
|
||||
{
|
||||
id: "notificationChannels",
|
||||
key: "notificationChannels",
|
||||
group: "notifications",
|
||||
width: 6,
|
||||
component: NotificationChannelsCard,
|
||||
},
|
||||
{
|
||||
id: "maintenance",
|
||||
key: "maintenance",
|
||||
group: "maintenance",
|
||||
width: 6,
|
||||
component: MaintenanceCard,
|
||||
},
|
||||
{
|
||||
id: "statusPages",
|
||||
key: "statusPages",
|
||||
group: "statusPages",
|
||||
width: 6,
|
||||
component: StatusPagesCard,
|
||||
},
|
||||
{
|
||||
id: "checkmateServer",
|
||||
key: "checkmateServer",
|
||||
group: "logs",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: CheckmateServerCard,
|
||||
},
|
||||
{
|
||||
id: "checksOnSchedule",
|
||||
key: "checksOnSchedule",
|
||||
group: "logs",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: ChecksOnScheduleCard,
|
||||
},
|
||||
{
|
||||
id: "recentErrors",
|
||||
key: "recentErrors",
|
||||
group: "logs",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: RecentErrorsCard,
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
key: "team",
|
||||
group: "settings",
|
||||
width: 6,
|
||||
adminOnly: true,
|
||||
component: TeamCard,
|
||||
},
|
||||
];
|
||||
|
||||
const BY_ID = new Map<CardId, CardDefinition>(
|
||||
CARD_REGISTRY.map((card) => [card.id, card])
|
||||
);
|
||||
|
||||
export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { setDashboardCards } from "@/Features/UI/uiSlice";
|
||||
import { CardIds, DEFAULT_CARD_IDS, type CardId } from "./cards";
|
||||
import { CARD_REGISTRY, getCard } from "./registry";
|
||||
|
||||
import type { CardDefinition } from "./cards";
|
||||
import type { RootState } from "@/store";
|
||||
|
||||
const isCardId = (value: unknown): value is CardId =>
|
||||
typeof value === "string" && (CardIds as readonly string[]).includes(value);
|
||||
|
||||
export const useCardSelection = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// null means the user has never customised; fall back to defaults.
|
||||
const stored = useSelector((state: RootState) => state.ui.dashboardCards);
|
||||
const selected: CardId[] = useMemo(
|
||||
() => (stored !== null ? stored.filter(isCardId) : [...DEFAULT_CARD_IDS]),
|
||||
[stored]
|
||||
);
|
||||
|
||||
const isVisible = useCallback(
|
||||
(card: CardDefinition) => !card.adminOnly || isAdmin,
|
||||
[isAdmin]
|
||||
);
|
||||
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
selected
|
||||
.map(getCard)
|
||||
.filter((card): card is CardDefinition => card !== undefined)
|
||||
.filter(isVisible),
|
||||
[selected, isVisible]
|
||||
);
|
||||
|
||||
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
|
||||
|
||||
const addCard = useCallback(
|
||||
(id: CardId) => {
|
||||
if (!selected.includes(id)) dispatch(setDashboardCards([...selected, id]));
|
||||
},
|
||||
[dispatch, selected]
|
||||
);
|
||||
|
||||
const removeCard = useCallback(
|
||||
(id: CardId) => {
|
||||
dispatch(setDashboardCards(selected.filter((cardId) => cardId !== id)));
|
||||
},
|
||||
[dispatch, selected]
|
||||
);
|
||||
|
||||
const resetCards = useCallback(() => dispatch(setDashboardCards(null)), [dispatch]);
|
||||
|
||||
const selectAll = useCallback(
|
||||
() =>
|
||||
dispatch(
|
||||
setDashboardCards(
|
||||
available.map((card) => card.id).filter((id) => !selected.includes(id)).length >
|
||||
0
|
||||
? [...new Set([...selected, ...available.map((card) => card.id)])]
|
||||
: selected
|
||||
)
|
||||
),
|
||||
[dispatch, available, selected]
|
||||
);
|
||||
|
||||
const deselectAll = useCallback(() => dispatch(setDashboardCards([])), [dispatch]);
|
||||
|
||||
/**
|
||||
* Reorders cards based on DnD source/dest indices into the visible `cards` array.
|
||||
* Hidden admin cards keep their stored positions unchanged.
|
||||
*/
|
||||
const reorderCards = useCallback(
|
||||
(sourceIndex: number, destIndex: number) => {
|
||||
const visibleIds = cards.map((c) => c.id);
|
||||
const reordered = [...visibleIds];
|
||||
const [moved] = reordered.splice(sourceIndex, 1);
|
||||
reordered.splice(destIndex, 0, moved);
|
||||
let visibleIdx = 0;
|
||||
dispatch(
|
||||
setDashboardCards(
|
||||
selected.map((cardId) =>
|
||||
visibleIds.includes(cardId) ? reordered[visibleIdx++] : cardId
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
[dispatch, cards, selected]
|
||||
);
|
||||
|
||||
const allSelected = available.every((card) => selected.includes(card.id));
|
||||
|
||||
return {
|
||||
cards,
|
||||
available,
|
||||
selected,
|
||||
addCard,
|
||||
removeCard,
|
||||
reorderCards,
|
||||
resetCards,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
allSelected,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
|
||||
import { REFRESH_INTERVAL_MS } from "./cards";
|
||||
|
||||
// One request feeds every monitor-derived card, read from context so adding a
|
||||
// card costs no extra call. No rowsPerPage: the dashboard summarises the whole
|
||||
// fleet, and the repository treats its absence as "no limit".
|
||||
const buildMonitorsUrl = () => {
|
||||
const params = new URLSearchParams();
|
||||
MonitorTypes.forEach((type) => params.append("type", type));
|
||||
return `/monitors/team/with-checks?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const useMonitorsRequest = () =>
|
||||
useGet<MonitorsWithChecksResponse>(
|
||||
buildMonitorsUrl(),
|
||||
{},
|
||||
{ refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
|
||||
);
|
||||
|
||||
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
|
||||
|
||||
export const MonitorsContext = createContext<MonitorsRequest | null>(null);
|
||||
|
||||
export const useMonitors = (): MonitorsRequest => {
|
||||
const context = useContext(MonitorsContext);
|
||||
if (!context) {
|
||||
throw new Error("useMonitors must be used inside the dashboard's MonitorsContext");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
Whitespace-only changes.
@@ -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,113 @@
|
||||
// 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";
|
||||
|
||||
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),
|
||||
},
|
||||
{
|
||||
title: t("common.charts.labels.memoryUsage"),
|
||||
key: "avgMemoryUsedBytes",
|
||||
color: theme.palette.primary.main,
|
||||
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
||||
},
|
||||
];
|
||||
|
||||
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
||||
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}
|
||||
/>
|
||||
</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,84 @@
|
||||
// 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 = "logs" }: 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} />}
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user