Compare commits

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

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

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+138
View File
@@ -0,0 +1,138 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+113
View File
@@ -0,0 +1,113 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+2 -1
View File
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
ORIGIN="localhost" ORIGIN="localhost"
LOG_LEVEL="debug" LOG_LEVEL="debug"
``` ```
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
### Client `.env` ### Client `.env`
```env ```env
@@ -109,7 +110,7 @@ client/src/
- **Data Fetching**: SWR + Axios - **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM - **Database**: MongoDB with Mongoose ODM
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling) - **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service) - **i18n**: i18next + react-i18next (translations via PoEditor)
--- ---
+7 -5
View File
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173) - `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure) - `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL - `DB_CONNECTION_STRING`: MongoDB connection URL
- `ORIGIN`: Origin for CORS purposes - `ORIGIN`: Origin for CORS purposes (optional)
- `TOKEN_TTL`: Token time to live (in vercel/ms format) - `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
- `LOG_LEVEL`: Debug level (debug, info, warn, error) - `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`) - `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
Start the backend server: Start the backend server:
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
**Port already in use:** **Port already in use:**
- Check if another service is using ports 5173, 52345, 27017, or 6379 - Check if another service is using ports 5173, 52345, or 27017
- Stop the conflicting service or change the port in `.env` files - Stop the conflicting service or change the port in `.env` files
**MongoDB connection issues:** **MongoDB connection issues:**
+8 -15
View File
@@ -32,13 +32,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1> <h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong> <p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" /> <img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br> <br>
@@ -81,9 +80,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a> <a id="installation"></a>
## Installation ## Installation
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service. The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
```bash ```bash
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
@@ -92,10 +89,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345. Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration ### Configuration
The image is configured entirely through environment variables on the server container: The image is configured entirely through environment variables on the server container:
@@ -123,9 +116,9 @@ Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https:
### Using a Custom CA ### Using a Custom CA
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](https://checkmate.so/docs/settings/custom-ca-trust) for Docker configuration options. If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
For more documentation, see the [Checkmate documentation](https://checkmate.so/docs). For more documentation, see the [docs directory](./docs/).
<a id="performance"></a> <a id="performance"></a>
## Performance ## Performance
@@ -158,7 +151,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Selective disk monitoring with mountpoint selection - Selective disk monitoring with mountpoint selection
- Incidents at a glance - Incidents at a glance
- Status pages with 4 beautiful themes - Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance - Scheduled maintenance
- JSON query monitoring - JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese - Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
@@ -212,9 +205,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a> <a id="contributing"></a>
## Contributing ## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe. We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us! Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "client", "name": "client",
"version": "3.11.0", "version": "3.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "client", "name": "client",
"version": "3.11.0", "version": "3.9.2",
"dependencies": { "dependencies": {
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
+1 -2
View File
@@ -1,10 +1,9 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.11.0", "version": "3.9.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development", "build-dev": "tsc -b && vite build --mode development",
+1 -10
View File
@@ -3,7 +3,6 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { useTheme } from "@mui/material/styles";
export type ActionMenuItem = { export type ActionMenuItem = {
id: number | string; id: number | string;
@@ -13,7 +12,6 @@ export type ActionMenuItem = {
}; };
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<null | any>(null); const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
@@ -29,14 +27,7 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return ( return (
<div> <div>
<IconButton <IconButton onClick={handleClick}>
onClick={handleClick}
sx={{
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
}}
>
<Settings <Settings
size={20} size={20}
strokeWidth={1.5} strokeWidth={1.5}
@@ -1,7 +1,7 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { CheckSnapshot } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
</Typography> </Typography>
<Typography> <Typography>
{t("common.labels.responseTime")}:{" "} {t("common.labels.responseTime")}:{" "}
{check?.responseTime ? formatMs(check.responseTime) : "N/A"} {check?.originalResponseTime?.toFixed() ?? "N/A"} ms
</Typography> </Typography>
<Typography textTransform={"capitalize"}> <Typography textTransform={"capitalize"}>
Status:{" "} Status:{" "}
@@ -8,8 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
import { formatMs } from "@/Utils/TimeUtils";
interface HistogramResponseTimeProps { interface HistogramResponseTimeProps {
checks: CheckSnapshot[]; checks: CheckSnapshot[];
@@ -20,25 +18,25 @@ interface HistogramResponseTimeProps {
} }
interface ResponseTimeStats { interface ResponseTimeStats {
max: string | null; max: number | string;
avg: string | null; avg: number | string;
} }
const DEFAULT_HEIGHT = 50; const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) { if (!Array.isArray(checks) || checks.length === 0) {
return { max: null, avg: null }; return { max: "-", avg: "-" };
} }
const validChecks = checks.filter((check) => check.responseTime != null); const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) { if (validChecks.length === 0) {
return { max: null, avg: null }; return { max: "-", avg: "-" };
} }
const responseTimes = validChecks.map((check) => check.responseTime); const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = formatMs(Math.max(...responseTimes)); const max = Math.round(Math.max(...responseTimes));
const avg = formatMs( const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
); );
@@ -56,7 +54,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation(); const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]); const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null; if (!Array.isArray(checks) || checks.length === 0) return null;
const data = const data =
@@ -70,28 +68,29 @@ export const HistogramResponseTime = ({
const chartHeight = typeof height === "number" ? `${height}px` : height; const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5); const gridGap = gap ?? theme.spacing(0.5);
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && ( const statsContent = showStats &&
<Stack (typeof stats.max === "number" || typeof stats.avg === "number") && (
justifyContent="center" <Stack
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"} justifyContent="center"
width={110} alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
pr={statsPosition === "left" ? theme.spacing(8) : 0} width={110}
pl={statsPosition === "right" ? theme.spacing(8) : 0} pr={statsPosition === "left" ? theme.spacing(8) : 0}
> pl={statsPosition === "right" ? theme.spacing(8) : 0}
<Typography
variant="body2"
noWrap
> >
{t("common.charts.histogram.avg", { value: stats.avg })} <Typography
</Typography> variant="body2"
<Typography noWrap
variant="body2" >
noWrap {t("common.charts.histogram.avg", { value: stats.avg })}
> </Typography>
{t("common.charts.histogram.max", { value: stats.max })} <Typography
</Typography> variant="body2"
</Stack> noWrap
); >
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return ( return (
<Stack <Stack
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
> >
{data.map((check, index) => { {data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder"; const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${barHeights[index] ?? 0}%`; const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor = const barColor =
check.status === true check.status === true
? theme.palette.success.light ? theme.palette.success.light
@@ -6,13 +6,12 @@ import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material"; import { useMediaQuery } from "@mui/material";
import type { DateRange } from "@/Types/Query";
interface MonitorTimeFrameHeaderProps { interface MonitorTimeFrameHeaderProps {
isLoading?: boolean; isLoading?: boolean;
hasDateRange?: boolean; hasDateRange?: boolean;
dateRange: DateRange; dateRange: string;
setDateRange: (dateRange: DateRange) => void; setDateRange: (dateRange: string) => void;
} }
export const HeaderTimeRange = ({ export const HeaderTimeRange = ({
@@ -26,7 +25,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md")); const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = ( const handleChange = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
newValue: DateRange | null newValue: string | null
) => { ) => {
if (newValue !== null) { if (newValue !== null) {
setDateRange(newValue); setDateRange(newValue);
@@ -10,66 +10,39 @@ import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{ type BaseChartProps = React.PropsWithChildren<{
icon?: React.ReactNode; icon?: React.ReactNode;
title: string; title: string;
rightTitle?: string;
width?: number | string; width?: number | string;
maxWidth?: number | string; maxWidth?: number | string;
flexBasis?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>; padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>; }>;
export const BaseChart = ({ export const BaseChart = ({
children, children,
title, title,
rightTitle,
width = "100%", width = "100%",
maxWidth = "100%", maxWidth = "100%",
flexBasis = "0%",
padding, padding,
onClick,
}: BaseChartProps) => { }: BaseChartProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<BaseBox <BaseBox
onClick={onClick}
sx={{ sx={{
padding: padding ?? theme.spacing(LAYOUT.MD), padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex", display: "flex",
flex: 1, flex: 1,
flexBasis,
width: width, width: width,
maxWidth: maxWidth, maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}} }}
> >
<Stack <Stack
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(LAYOUT.MD)}
flex={1} flex={1}
> >
<Stack <Typography
direction="row" variant="eyebrow"
justifyContent={"space-between"} color="text.secondary"
> >
<Typography {title}
variant="eyebrow" </Typography>
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> <Box flex={1}>{children}</Box>
</Stack> </Stack>
</BaseBox> </BaseBox>
@@ -83,17 +83,12 @@ export const Breadcrumb = ({
const basePage = segments[0] || t("common.breadcrumbs.home"); const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1]; const secondSegment = segments[1];
const thirdSegment = segments[2];
const isActionPage = secondSegment && actionSegments.includes(secondSegment); const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment); const isDetailsPage = secondSegment && isId(secondSegment);
// page/section/:id routes such as docker/host/:monitorId const hasSubPage = isActionPage || isDetailsPage;
const isSectionDetailsPage = Boolean(
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
);
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const getSubPageLabel = (): string => { const getSubPageLabel = (): string => {
if (isActionPage || isSectionDetailsPage) { if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1); return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
} }
return t("common.breadcrumbs.details"); return t("common.breadcrumbs.details");
+4 -14
View File
@@ -1,28 +1,18 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps { interface DotProps {
color?: string; color?: string;
size?: DotSize; size?: string;
style?: CSSProperties; style?: CSSProperties;
} }
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => { export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return ( return (
<span <span
style={{ style={{
content: '""', content: '""',
width: DOT_SIZE_PX[size], width: size,
height: DOT_SIZE_PX[size], height: size,
borderRadius: "50%", borderRadius: "50%",
backgroundColor: color, backgroundColor: color,
opacity: 0.8, opacity: 0.8,
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
() => ({ () => ({
circumference: 2 * Math.PI * radius, circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2, totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius), strokeLength: (progress / 100) * (2 * Math.PI * radius),
}), }),
[radius, strokeWidth, progressWithinRange] [radius, strokeWidth, progress]
); );
const [offset, setOffset] = useState(circumference); const [offset, setOffset] = useState(circumference);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [circumference, strokeLength]); }, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme); const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,7 +123,6 @@ export const DetailGauge = ({
lowerLabel, lowerLabel,
lowerValue, lowerValue,
maxWidth = 225, maxWidth = 225,
flexBasis = "0%",
}: { }: {
title: string; title: string;
progress: number; progress: number;
@@ -132,7 +131,6 @@ export const DetailGauge = ({
lowerLabel?: string; lowerLabel?: string;
lowerValue?: string | number; lowerValue?: string | number;
maxWidth?: number; maxWidth?: number;
flexBasis?: number | string;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
@@ -140,7 +138,6 @@ export const DetailGauge = ({
icon={null} icon={null}
title={title} title={title}
maxWidth={maxWidth} maxWidth={maxWidth}
flexBasis={flexBasis}
> >
<Stack <Stack
alignItems={"center"} alignItems={"center"}
@@ -5,15 +5,13 @@ interface IconProps {
size?: number; size?: number;
strokeWidth?: number; strokeWidth?: number;
stroke?: string; stroke?: string;
color?: string;
} }
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => { const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
return ( return (
<Icon <Icon
size={size} size={size}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
color={color}
/> />
); );
}; };
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette"; import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<> <>
<Dot /> <Dot />
<Typography> <Typography>
Checking every {formatDuration(monitor?.interval, true)} Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography> </Typography>
</> </>
)} )}
@@ -11,10 +11,9 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{ type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey; palette?: PaletteKey;
sx?: SxProps; sx?: SxProps;
interactive?: boolean;
}>; }>;
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => { export const GradientBox = ({ children, palette, sx }: GradientBox) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light"; const isLight = theme.palette.mode === "light";
@@ -31,12 +30,6 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
padding: `${theme.spacing(4)} ${theme.spacing(8)}`, padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`, width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg, background: bg,
...(interactive && {
cursor: "pointer",
"&:hover": {
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
},
}),
...sx, ...sx,
}} }}
> >
@@ -69,8 +62,10 @@ export const StatBox = ({
return ( return (
<GradientBox <GradientBox
palette={palette} palette={palette}
interactive={Boolean(onClick)} sx={{
sx={{ ...(sx as object) }} ...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
> >
<Stack onClick={onClick}> <Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}> <Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
@@ -1,19 +1,12 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements"; import { BaseBox } from "@/Components/design-elements";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { MonitorStatus } from "@/Types/Monitor"; import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles"; import type { SxProps } from "@mui/material/styles";
import { import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
getDockerStatePalette,
getStatusPalette,
getValuePalette,
} from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; 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 const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number]; export type ValueType = (typeof ValueTypes)[number];
@@ -117,36 +110,3 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
</BaseBox> </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>
);
};
@@ -79,7 +79,6 @@ export function DataTable<
}; };
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) { if (data.length === 0 || headers.length === 0) {
return ( return (
@@ -109,11 +108,6 @@ export function DataTable<
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS), padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default", cursor: onRowClick ? "pointer" : "default",
...(onRowClick && {
"&:hover": {
backgroundColor: theme.palette.action.rowHover,
},
}),
}} }}
key={key} key={key}
> >
@@ -213,10 +207,6 @@ export function DataTable<
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": { "& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none", borderBottom: "none",
}, },
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.rowHover,
},
}} }}
> >
<TableHead> <TableHead>
@@ -241,9 +231,8 @@ export function DataTable<
return ( return (
<Fragment key={key}> <Fragment key={key}>
<TableRow <TableRow
className={isInteractive ? "is-clickable" : undefined}
sx={{ sx={{
cursor: isInteractive ? "pointer" : "default", cursor: onRowClick ? "pointer" : "default",
...(getRowSx?.(row) as object), ...(getRowSx?.(row) as object),
}} }}
onClick={() => { onClick={() => {
@@ -303,12 +292,6 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void; onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
} }
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
});
function TablePaginationActions(props: TablePaginationActionsProps) { function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme(); const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props; const { count, page, rowsPerPage, onPageChange } = props;
@@ -338,7 +321,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick} onClick={handleFirstPageButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="first page" aria-label="first page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronsRight <ChevronsRight
@@ -356,7 +338,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleBackButtonClick} onClick={handleBackButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="previous page" aria-label="previous page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronRight <ChevronRight
@@ -374,7 +355,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleNextButtonClick} onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1} disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page" aria-label="next page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronLeft <ChevronLeft
@@ -392,7 +372,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleLastPageButtonClick} onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1} disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page" aria-label="last page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronsLeft <ChevronsLeft
@@ -440,7 +419,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleFirstPageButtonClick} onClick={handleFirstPageButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="first page" aria-label="first page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronsRight <ChevronsRight
@@ -458,7 +436,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleBackButtonClick} onClick={handleBackButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="previous page" aria-label="previous page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronRight <ChevronRight
@@ -476,7 +453,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleNextButtonClick} onClick={handleNextButtonClick}
disabled={hasMore === false} disabled={hasMore === false}
aria-label="next page" aria-label="next page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronLeft <ChevronLeft
@@ -494,7 +470,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
<IconButton <IconButton
disabled={hasMore === false} disabled={hasMore === false}
aria-label="next page" aria-label="next page"
sx={controlHoverSx(theme)}
> >
<Ellipsis <Ellipsis
size={20} size={20}
+1 -11
View File
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type AutoCompleteInputProps = Omit< type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>, AutocompleteProps<any, boolean, boolean, boolean>,
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode; renderOptionContent?: (option: any) => React.ReactNode;
placeholder?: string;
error?: boolean;
helperText?: string;
}; };
export const AutoCompleteInput = ({ export const AutoCompleteInput = ({
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
required, required,
renderInput, renderInput,
renderOptionContent, renderOptionContent,
placeholder,
error,
helperText,
...props ...props
}: AutoCompleteInputProps) => { }: AutoCompleteInputProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation();
const multiple = props.multiple; const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props; const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => ( const defaultRenderInput = (params: any) => (
<TextField <TextField
{...params} {...params}
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")} placeholder="Type to search"
error={error}
helperText={helperText}
/> />
); );
-3
View File
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
"& .MuiButtonBase-root": { "& .MuiButtonBase-root": {
mr: theme.spacing(6), mr: theme.spacing(6),
}, },
"&:has(.Mui-focusVisible)": {
backgroundColor: theme.palette.action.hover,
},
}} }}
/> />
); );
+4 -15
View File
@@ -11,11 +11,10 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
placeholder?: string; placeholder?: string;
helperText?: string;
} }
const SelectInputInner = <T,>( const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>, { fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement> ref: React.ForwardedRef<HTMLDivElement>
) => { ) => {
const theme = useTheme(); const theme = useTheme();
@@ -28,7 +27,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === ""; : selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) { if (isEmpty && placeholder) {
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>; return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
} }
if (isMultiple) { if (isMultiple) {
@@ -93,21 +92,11 @@ const SelectInputInner = <T,>(
/> />
); );
const helper = helperText ? ( if (fieldLabel) {
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
return ( return (
<Stack spacing={theme.spacing(2)}> <Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>} <FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select} {select}
{helper}
</Stack> </Stack>
); );
} }
+12 -21
View File
@@ -3,7 +3,6 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers"; import { LocalizationProvider } from "@mui/x-date-pickers";
@@ -11,15 +10,11 @@ import { LocalizationProvider } from "@mui/x-date-pickers";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> { interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
error?: boolean;
helperText?: string;
} }
export const TimePickerComponent = ({ export const TimePickerComponent = ({
fieldLabel, fieldLabel,
required, required,
error,
helperText,
...props ...props
}: TimePickerComponentProps) => { }: TimePickerComponentProps) => {
const theme = useTheme(); const theme = useTheme();
@@ -38,12 +33,12 @@ export const TimePickerComponent = ({
px: theme.spacing(5), px: theme.spacing(5),
}, },
"& fieldset": { "& fieldset": {
borderColor: error ? theme.palette.error.main : theme.palette.divider, borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
}, },
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{ {
borderColor: error ? theme.palette.error.main : theme.palette.divider, borderColor: theme.palette.divider,
}, },
}, },
}, },
@@ -53,18 +48,14 @@ export const TimePickerComponent = ({
</LocalizationProvider> </LocalizationProvider>
); );
return ( if (fieldLabel) {
<Stack spacing={theme.spacing(2)}> return (
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>} <Stack spacing={theme.spacing(2)}>
{picker} <FieldLabel required={required}>{fieldLabel}</FieldLabel>
{helperText && ( {picker}
<Typography </Stack>
variant="caption" );
color={error ? "error" : "text.secondary"} }
>
{helperText} return picker;
</Typography>
)}
</Stack>
);
}; };
@@ -1,52 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
export interface AutocompleteOption {
id: string;
name: string;
}
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
}
export const FormAutocompleteField = <
T extends FieldValues,
O extends AutocompleteOption,
>({
name,
options,
fieldLabel,
placeholder,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Autocomplete
options={options}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -1,38 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Checkbox } from "@/Components/inputs";
interface FormCheckboxFieldProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
}
export const FormCheckboxField = <T extends FieldValues>({
name,
label,
}: FormCheckboxFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label={label}
/>
)}
/>
);
};
@@ -1,35 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { ColorInput } from "@/Components/inputs";
interface FormColorFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
}
export const FormColorField = <T extends FieldValues>({
name,
fieldLabel,
}: FormColorFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={fieldLabel}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -1,81 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import dayjs, { type Dayjs } from "dayjs";
import { DatePicker, TimePicker } from "@/Components/inputs";
export interface FormPickerFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
format?: string;
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
}
interface DayjsPickerProps {
fieldLabel?: string;
value: Dayjs | null;
onChange: (value: Dayjs | null) => void;
error?: boolean;
helperText?: string;
}
interface FormDayjsPickerFieldProps<
T extends FieldValues,
> extends FormPickerFieldProps<T> {
picker: React.ComponentType<DayjsPickerProps>;
format: string;
}
const FormDayjsPickerField = <T extends FieldValues>({
name,
fieldLabel,
format,
onValueChange,
picker: Picker,
}: FormDayjsPickerFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Picker
fieldLabel={fieldLabel}
value={field.value ? dayjs(field.value, format) : null}
onChange={(value) => {
const next = value ? value.format(format) : "";
field.onChange(next);
onValueChange?.(next);
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
);
};
export const FormDatePickerField = <T extends FieldValues>({
format = "YYYY-MM-DD",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={DatePicker}
/>
);
export const FormTimePickerField = <T extends FieldValues>({
format = "HH:mm",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={TimePicker}
/>
);
@@ -1,187 +0,0 @@
import { useMemo } from "react";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import { useTranslation } from "react-i18next";
import { Autocomplete } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
export interface MultiSelectOption {
id: string | number;
name: string;
}
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
description?: string;
renderRow?: (option: O) => React.ReactNode;
renderOptionContent?: (option: O) => React.ReactNode;
sortable?: boolean;
}
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
name,
options,
fieldLabel,
placeholder,
description,
renderRow,
renderOptionContent,
sortable = false,
}: FormMultiSelectFieldProps<T, O>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
const { t } = useTranslation();
const optionsById = useMemo(
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
[options]
);
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
const selectedIds: O["id"][] = field.value ?? [];
const selected = selectedIds
.map((id) => optionsById.get(id))
.filter((o): o is O => o !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = selected.map((o) => o.id);
const [moved] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, moved);
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...reordered, ...unresolved]);
};
const rowContent = (option: O) =>
renderRow ? (
renderRow(option)
) : (
<Typography flexGrow={1}>{option.name}</Typography>
);
const removeButton = (option: O) => (
<IconButton
size="small"
onClick={() => {
field.onChange(selectedIds.filter((id) => id !== option.id));
}}
aria-label={t("common.buttons.removeItem", { item: option.name })}
>
<Trash2 size={16} />
</IconButton>
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={options}
value={selected}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(_: unknown, newValue: O[]) => {
// The Autocomplete only sees resolved options, so ids missing
// from options must be re-appended or they'd be silently
// dropped (e.g. a role excluded from the selectable list).
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
}}
fieldLabel={fieldLabel}
placeholder={selected.length > 0 ? "" : placeholder}
renderOptionContent={renderOptionContent}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
{description && (
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{description}
</Typography>
)}
{sortable
? selected.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={`${name}-list`}>
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selected.map((option, index) => (
<Draggable
key={option.id}
draggableId={String(option.id)}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
{rowContent(option)}
{removeButton(option)}
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)
: selected.map((option) => (
<Stack
key={option.id}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
width="100%"
>
{rowContent(option)}
{removeButton(option)}
</Stack>
))}
</Stack>
);
}}
/>
);
};
@@ -1,46 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText" | "type"
> {
name: FieldPath<T>;
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
}
export const FormNumberField = <T extends FieldValues>({
name,
emptyValue = 0,
...rest
}: FormNumberFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
value={
field.value === undefined || field.value === emptyValue ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? emptyValue : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
{...rest}
/>
)}
/>
);
};
@@ -1,54 +0,0 @@
import { RadioGroup, useTheme } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { LAYOUT } from "@/Utils/Theme/constants";
import { RadioWithDescription } from "@/Components/inputs/Radio";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
export interface FormRadioOption {
value: string;
label: string;
description: string;
}
interface FormRadioGroupProps<T extends FieldValues> {
name: FieldPath<T>;
options: FormRadioOption[];
fieldLabel?: string;
}
export const FormRadioGroup = <T extends FieldValues>({
name,
options,
fieldLabel,
}: FormRadioGroupProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
>
{options.map((option) => (
<RadioWithDescription
key={option.value}
value={option.value}
label={option.label}
description={option.description}
/>
))}
</RadioGroup>
</FormControl>
)}
/>
);
};
@@ -1,61 +0,0 @@
import {
Controller,
useFormContext,
type FieldValues,
type FieldPath,
} from "react-hook-form";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/inputs";
export interface FormSelectOption {
value: string | number;
label: string;
}
interface FormSelectProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof Select>,
"name" | "error" | "value" | "onChange"
> {
name: FieldPath<T>;
options: FormSelectOption[];
fallbackValue?: string | number; // display value while the field is undefined
onValueChange?: (value: string | number) => void; // side effects, see Notes
}
export const FormSelectField = <T extends FieldValues>({
name,
options,
fallbackValue,
onValueChange,
...rest
}: FormSelectProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? fallbackValue ?? ""}
onChange={(e) => {
field.onChange(e.target.value);
onValueChange?.(e.target.value as string | number);
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
);
};
@@ -1,34 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { SliderWithLabel } from "@/Components/inputs";
interface FormSliderProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof SliderWithLabel>,
"name" | "value" | "onChange"
> {
name: FieldPath<T>;
}
export const FormSliderField = <T extends FieldValues>({
name,
...rest
}: FormSliderProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
{...rest}
/>
)}
/>
);
};
@@ -1,59 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { SwitchComponent } from "@/Components/inputs";
import { SPACING } from "@/Utils/Theme/constants";
interface FormSwitchProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
labelPlacement?: "start" | "end"; // left | right
}
export const FormSwitchField = <T extends FieldValues>({
name,
label,
labelPlacement = "end",
}: FormSwitchProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
>
{labelPlacement === "start" ? (
<>
<Typography>{label}</Typography>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
</>
) : (
<>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>{label}</Typography>
</>
)}
</Stack>
)}
/>
);
};
@@ -1,40 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormTextFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText"
> {
name: FieldPath<T>;
helperText?: string;
}
export const FormTextField = <T extends FieldValues>({
name,
...rest
}: FormTextFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
{...rest}
value={field.value ?? ""}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
fullWidth
/>
)}
/>
);
};
+9 -7
View File
@@ -18,13 +18,15 @@ const RootLayout = () => {
flex={1} flex={1}
padding={6} padding={6}
overflow={"hidden"} overflow={"hidden"}
bgcolor={ sx={{
theme.palette.mode === "dark" backgroundColor:
? "rgba(255, 255, 255, 0.01)" theme.palette.mode === "dark"
: "rgba(124, 116, 116, 0.01)" ? "rgba(255, 255, 255, 0.01)"
} : "rgba(0, 0, 0, 0.01)",
alignItems="center" display: "flex",
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12} alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
> >
<Stack <Stack
maxWidth={1280} maxWidth={1280}
@@ -9,11 +9,12 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material"; import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "game", "grpc", "websocket"]; const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = { const typeDisplayNames: Record<string, string> = {
http: "HTTP", http: "HTTP",
ping: "Ping", ping: "Ping",
port: "Port", port: "Port",
docker: "Docker",
game: "Game", game: "Game",
grpc: "gRPC", grpc: "gRPC",
websocket: "WebSocket", websocket: "WebSocket",
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode"; import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { formatMs } from "@/Utils/TimeUtils";
interface GeoChecksMapProps { interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[]; geoChecks: FlatGeoCheck[];
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
return status ? theme.palette.success.main : theme.palette.error.main; return status ? theme.palette.success.main : theme.palette.error.main;
}; };
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return ( return (
<> <>
{mapPopupStyles} {mapPopupStyles}
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
> >
Response Time: Response Time:
</Typography>{" "} </Typography>{" "}
{formatMs(selectedCheck.timings.total)} {formatResponseTime(selectedCheck.timings.total)}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
onChange={(event) => {
c.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={c.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -1,24 +1,22 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements"; import { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor"; import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils"; import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
interface MonitorStatBoxesProps { interface MonitorStatBoxesProps {
monitor?: Monitor; monitor?: Monitor;
monitorStats: MonitorStats | null; monitorStats: MonitorStats | null;
certificateExpiry?: string; certificateExpiry?: string;
domainExpiry?: string;
} }
export const MonitorStatBoxes = ({ export const MonitorStatBoxes = ({
monitor, monitor,
monitorStats, monitorStats,
certificateExpiry, certificateExpiry,
domainExpiry,
}: MonitorStatBoxesProps) => { }: MonitorStatBoxesProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0; const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure); const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const lastCheckTime = formatDuration(timeSinceLastCheck); const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive = const isActive =
monitor?.status === "up" || monitor?.status === "up" ||
monitor?.status === "paused" || monitor?.status === "paused" ||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
/> />
<StatBox <StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")} title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)} subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/> />
{monitor?.type === "http" && ( {monitor?.type === "http" && (
<> <StatBox
<StatBox title={t("pages.common.monitors.statBoxes.certificateExpiry")}
title={t("pages.common.monitors.statBoxes.certificateExpiry")} subtitle={certificateExpiry || "N/A"}
subtitle={certificateExpiry || "N/A"} />
/>
<StatBox
title={t("pages.common.monitors.statBoxes.domainExpiry")}
subtitle={domainExpiry || "N/A"}
/>
</>
)} )}
</Stack> </Stack>
); );
@@ -1,113 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
export const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
@@ -1,39 +1,53 @@
import Stack from "@mui/material/Stack";
import { BaseChart, BaseBox } from "@/Components/design-elements"; import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import { import {
AreaChart, AreaChart,
Area, Area,
XAxis, XAxis,
YAxis,
Tooltip, Tooltip,
CartesianGrid, CartesianGrid,
ResponsiveContainer, ResponsiveContainer,
Text,
} from "recharts"; } from "recharts";
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next"; import {
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
import { useMemo, useState } from "react"; type XTickProps = {
import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; x: number;
import { y: number;
PHASE_COLOR_KEYS, payload: { value: string };
PHASE_KEYS, range: string;
PHASE_LABEL_KEYS, };
} from "@/Components/monitors/charts/ChartUtils";
type DetailsCheck = GroupedCheck & export const XTick = ({ x, y, payload, range }: XTickProps) => {
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>; const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & { type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string; range: string;
@@ -49,65 +63,17 @@ const ResponseTimeToolTip = ({
theme, theme,
uiTimezone, uiTimezone,
}: ResponseTimeToolTipProps) => { }: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null; if (!label) return null;
if (!payload) return null; if (!payload) return null;
if (!active) return null; if (!active) return null;
const format = tooltipDateFormatLookup(range); const format = tooltipDateFormatLookup(range);
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0; console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return ( return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}> <BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography> <Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography> <Typography>Response time: {responseTime} ms</Typography>
{t("common.charts.histogram.responseTime", {
value: formatMs(responseTime),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: formatMs(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
</Typography>
</Stack>
))}
</BaseBox> </BaseBox>
); );
}; };
@@ -116,28 +82,11 @@ export const HistogramDetails = ({
checks, checks,
range, range,
}: { }: {
checks: DetailsCheck[]; checks: GroupedCheck[];
range: string; range: string;
}) => { }) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return ( return (
<BaseChart <BaseChart
icon={ icon={
@@ -146,15 +95,7 @@ export const HistogramDetails = ({
strokeWidth={1.5} strokeWidth={1.5}
/> />
} }
title={ title="Response times"
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
> >
<ResponsiveContainer <ResponsiveContainer
width="100%" width="100%"
@@ -187,34 +128,7 @@ export const HistogramDetails = ({
stopOpacity={0} stopOpacity={0}
/> />
</linearGradient> </linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs> </defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis <XAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -227,99 +141,24 @@ export const HistogramDetails = ({
)} )}
/> />
{showTimingPhases ? ( <Tooltip
<Tooltip content={(props) => (
content={(props) => ( <ResponseTimeToolTip
<TimingPhasesToolTip {...props}
{...props} range={range}
range={range} theme={theme}
theme={theme} uiTimezone={uiTimezone}
uiTimezone={uiTimezone} />
/> )}
)} />
/> <Area
) : ( type="monotone"
<Tooltip dataKey="avgResponseTime"
content={(props) => ( stroke={theme.palette.primary.main}
<ResponseTimeToolTip fill="url(#colorUv)"
{...props} />
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart> </BaseChart>
); );
}; };
@@ -1,70 +0,0 @@
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
stats: DockerContainerStats;
dataKey: string;
strokeColor: string;
gradientStartColor: string;
}
export const HistogramDockerContainer = ({
title,
rightTitle,
stats,
dataKey,
strokeColor,
gradientStartColor,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
return (
<BaseChart
icon="null"
title={title}
rightTitle={rightTitle}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={stats.aggregate}>
<CartesianGrid
strokeWidth={1}
stroke={theme.palette.divider}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Fragment>
{createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: "transparent",
direction: "vertical",
})}
<Area
dataKey={dataKey}
type="monotone"
stroke={strokeColor}
fill={`url(#${gradientId})`}
/>
</Fragment>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -8,11 +8,89 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { Fragment, useId } from "react"; import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors/charts/XTick"; import { XTick } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor"; import type { HardwareCheckStats } from "@/Types/Monitor";
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
export const HistogramInfrastructure = ({ export const HistogramInfrastructure = ({
dateRange, dateRange,
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements"; import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors/charts/XTick"; import { XTick } from "@/Components/monitors";
import { import {
XAxis, XAxis,
AreaChart, AreaChart,
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements"; import { BaseChart } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts"; import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { getResponseTimeColor } from "@/Utils/MonitorUtils";
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
</Typography> </Typography>
) : null} ) : null}
<Typography variant="caption">{titleText}</Typography> <Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{formatMs(avg)}</Typography> <Typography variant="body2">{Math.floor(avg)} ms</Typography>
</Stack> </Stack>
); );
}; };
@@ -7,7 +7,6 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { formatMs } from "@/Utils/TimeUtils";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => { export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -85,9 +84,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
<Typography <Typography
variant="h1" variant="h1"
textAlign={"center"} textAlign={"center"}
> >{`${avg?.toFixed()}ms`}</Typography>
{formatMs(avg)}
</Typography>
</Stack> </Stack>
</Stack> </Stack>
</BaseChart> </BaseChart>
@@ -1,30 +0,0 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure"; export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary"; export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar"; export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+9 -21
View File
@@ -10,11 +10,10 @@ import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react"; import { MoreVertical, LogOut } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements"; import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js"; import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js"; import type { RootState } from "@/Types/state.js";
import { setMode } from "@/Features/UI/uiSlice.js";
interface AuthFooterProps { interface AuthFooterProps {
collapsed: boolean; collapsed: boolean;
@@ -28,7 +27,6 @@ interface AuthFooterProps {
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => { export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const user = useSelector((state: RootState) => state.auth.user); const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Typography <Typography
fontWeight={500} fontWeight={500}
noWrap noWrap
color={theme.palette.text.primary} sx={{ color: theme.palette.text.primary }}
> >
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Typography> </Typography>
<Typography <Typography
noWrap noWrap
textTransform={"capitalize"} sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
color={theme.palette.text.secondary}
> >
{getRoleText()} {getRoleText()}
</Typography> </Typography>
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
pb={2} pb={2}
sx={{ pointerEvents: "none" }} sx={{ pointerEvents: "none" }}
> >
<Typography fontWeight={500}> <Typography
fontWeight={500}
fontSize={13}
>
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Typography> </Typography>
<Typography <Typography
variant="body2" fontSize={12}
textTransform={"capitalize"} sx={{ textTransform: "capitalize" }}
> >
{getRoleText()} {getRoleText()}
</Typography> </Typography>
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
{item.name} {item.name}
</MenuItem> </MenuItem>
))} ))}
<MenuItem
onClick={() => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
handleMenuClose();
}}
sx={menuItemSx}
>
<Icon icon={mode === "light" ? Moon : Sun} />
{mode === "light"
? t("pages.settings.form.ui.option.theme.dark")
: t("pages.settings.form.ui.option.theme.light")}
</MenuItem>
<MenuItem <MenuItem
onClick={handleLogout} onClick={handleLogout}
sx={menuItemSx} sx={menuItemSx}
+48 -79
View File
@@ -18,95 +18,64 @@ import {
Lock, Lock,
Users, Users,
Tag, Tag,
Waypoints,
Container,
} from "lucide-react"; } from "lucide-react";
export const getMenu = (t: Function) => { export const getMenu = (t: Function) => {
return [ return [
{ {
group: t("components.sidebar.menu.groups.monitoring"), name: t("components.sidebar.menu.uptime"),
items: [ path: "uptime",
{ icon: <Icon icon={Globe} />,
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
{
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
name: t("components.sidebar.menu.docker"),
path: "docker",
icon: <Icon icon={Container} />,
},
],
}, },
{ {
group: t("components.sidebar.menu.groups.alerting"), name: t("components.sidebar.menu.pagespeed"),
items: [ path: "pagespeed",
{ icon: <Icon icon={Gauge} />,
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} />,
},
],
}, },
{ {
group: t("components.sidebar.menu.groups.reporting"), name: t("components.sidebar.menu.infrastructure"),
items: [ path: "infrastructure",
{ icon: <Icon icon={Link} />,
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} />,
},
],
}, },
{ {
group: t("components.sidebar.menu.groups.configuration"), name: t("components.sidebar.menu.notifications"),
items: [ path: "notifications",
{ icon: <Icon icon={Bell} />,
name: t("components.sidebar.menu.tags"), },
path: "tags", {
icon: <Icon icon={Tag} />, 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.checks"),
}, path: "checks",
{ icon: <Icon icon={FileText} />,
name: t("components.sidebar.menu.settings"), },
path: "settings", {
icon: <Icon icon={Settings} />, 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",
}, },
]; ];
}; };
+12 -51
View File
@@ -1,12 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import Backdrop from "@mui/material/Backdrop"; import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack"; 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 List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useSidebar } from "@/Hooks/useSidebar.js"; import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo"; import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu"; import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -32,7 +29,7 @@ export const Sidebar = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { width, transition, transitionTiming, collapsed } = useSidebar(); const { width, transition, collapsed } = useSidebar();
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t); const menu = getMenu(t);
@@ -92,55 +89,19 @@ export const Sidebar = () => {
pt={theme.spacing(LAYOUT.MD)} pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)} pb={theme.spacing(LAYOUT.MD)}
/> />
{menu.map((group) => ( {menu.map((item) => {
<Box key={group.group}> const selected = location.pathname.startsWith(`/${item.path}`);
<Box return (
position="relative" <NavItem
overflow="hidden" key={item.path}
> item={item}
<Typography selected={selected}
variant="eyebrow" onClick={() => handleNavClick(item.path)}
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> </List>
<StarPrompt /> <StarPrompt />
<Divider sx={{ borderColor: theme.palette.divider }} /> <Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter <AuthFooter
+1 -16
View File
@@ -2,14 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line"; type ChartType = "histogram" | "line";
type TableName = type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed"
| "docker";
interface TableState { interface TableState {
rowsPerPage: number; rowsPerPage: number;
@@ -21,11 +14,9 @@ interface SidebarState {
interface UIState { interface UIState {
monitors: TableState; monitors: TableState;
pagespeed: TableState;
team: TableState; team: TableState;
maintenance: TableState; maintenance: TableState;
infrastructure: TableState; infrastructure: TableState;
docker: TableState;
logs: TableState; logs: TableState;
sidebar: SidebarState; sidebar: SidebarState;
mode: ThemeMode; mode: ThemeMode;
@@ -46,9 +37,6 @@ const initialState: UIState = {
monitors: { monitors: {
rowsPerPage: 10, rowsPerPage: 10,
}, },
pagespeed: {
rowsPerPage: 10,
},
team: { team: {
rowsPerPage: 5, rowsPerPage: 5,
}, },
@@ -58,9 +46,6 @@ const initialState: UIState = {
infrastructure: { infrastructure: {
rowsPerPage: 5, rowsPerPage: 5,
}, },
docker: {
rowsPerPage: 5,
},
logs: { logs: {
rowsPerPage: 15, rowsPerPage: 15,
}, },
+3 -12
View File
@@ -1,11 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions { interface UseMonitorFormOptions {
@@ -47,10 +42,8 @@ export const getMonitorDefaults = (
url: data?.url || "", url: data?.url || "",
method: data?.method ?? DefaultHttpMethod, method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false, ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false, useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod, matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "", expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "", jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [], customUpCodes: data?.customUpCodes || [],
@@ -144,10 +137,8 @@ export const getMonitorDefaults = (
url: "", url: "",
method: DefaultHttpMethod, method: DefaultHttpMethod,
ignoreTlsErrors: false, ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false, useAdvancedMatching: false,
matchMethod: DefaultMonitorMatchMethod, matchMethod: "",
expectedValue: "", expectedValue: "",
jsonPath: "", jsonPath: "",
customUpCodes: [], customUpCodes: [],
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
+80 -8
View File
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
} }
function buildDefaults(data: Notification | null): NotificationFormData { function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return { return {
type: data?.type ?? "email", type: "email",
notificationName: data?.notificationName || "", notificationName: data?.notificationName || "",
address: data?.address || "", address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
}; };
} }
-22
View File
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
-2
View File
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "", pagespeedApiKey: "",
systemEmailPassword: "", systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
}; };
return { schema: settingsSchema, defaults }; return { schema: settingsSchema, defaults };
+1 -3
View File
@@ -6,8 +6,7 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64; const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes // Transition timing for sidebar width changes
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
export const useSidebar = () => { export const useSidebar = () => {
const collapsed = useSelector( const collapsed = useSelector(
@@ -20,7 +19,6 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR, expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION, transition: SIDEBAR_TRANSITION,
transitionTiming: SIDEBAR_TRANSITION_TIMING,
}; };
}; };
+103 -39
View File
@@ -1,6 +1,11 @@
import { TextField, Button } from "@/Components/inputs"; import { Trash2 } from "lucide-react";
import { TextField, Button, Autocomplete } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { FormProvider, useForm } from "react-hook-form"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import FormHelperText from "@mui/material/FormHelperText";
import { useForm, Controller } from "react-hook-form";
import { ConfigBox, BasePage } from "@/Components/design-elements"; import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User"; import { UserRoles } from "@/Types/User";
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
interface RoleOption { interface RoleOption {
id: UserRole; id: UserRole;
@@ -40,11 +43,10 @@ const EditUserPage = () => {
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`); const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
const form = useForm<EditUserFormData>({ const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, reset } = form;
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -62,6 +64,13 @@ const EditUserPage = () => {
name: t(`common.auth.roles.${role}`), name: t(`common.auth.roles.${role}`),
})); }));
const watchedRoles = watch("role");
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
const handleRemoveRole = (roleToRemove: UserRole) => {
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser = const canDeleteUser =
isAdmin && isAdmin &&
userId !== currentUser?.id && userId !== currentUser?.id &&
@@ -81,31 +90,42 @@ const EditUserPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage loading={isLoading}>
<BasePage <form onSubmit={handleSubmit(onSubmit)}>
loading={isLoading}
component={"form"}
onSubmit={handleSubmit(onSubmit)}
>
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<ConfigBox <ConfigBox
title={t("pages.account.form.name.title")} title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")} subtitle={t("pages.account.form.name.description")}
rightContent={ rightContent={
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<FormTextField <Controller
name="firstName" name="firstName"
autoComplete="given-name" control={control}
fieldLabel={t("common.form.name.option.firstName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.firstName.placeholder")} <TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="given-name"
/>
)}
/> />
<FormTextField <Controller
name="lastName" name="lastName"
autoComplete="family-name" control={control}
fieldLabel={t("common.form.name.option.lastName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.lastName.placeholder")} <TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="family-name"
/>
)}
/> />
<TextField <TextField
fieldLabel={t("common.form.email.option.email.label")} fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")} placeholder={t("common.form.email.option.email.placeholder")}
@@ -119,11 +139,55 @@ const EditUserPage = () => {
title={t("pages.editUser.form.roles.title")} title={t("pages.editUser.form.roles.title")}
subtitle={t("pages.editUser.form.roles.description")} subtitle={t("pages.editUser.form.roles.description")}
rightContent={ rightContent={
<FormMultiSelectField <Stack spacing={theme.spacing(4)}>
name="role" <Controller
fieldLabel={t("common.form.role.option.role.label")} name="role"
options={roleOptions} control={control}
/> render={({ field, fieldState }) => (
<>
<Autocomplete
fieldLabel={t("common.form.role.option.role.label")}
multiple
options={roleOptions}
value={selectedRoles}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: RoleOption[]) => {
field.onChange(newValue.map((r) => r.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{selectedRoles.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedRoles.map((role, index) => (
<Stack
direction="row"
alignItems="center"
key={role.id}
width="100%"
>
<Typography flexGrow={1}>{role.name}</Typography>
<IconButton
size="small"
onClick={() => handleRemoveRole(role.id)}
aria-label="Remove role"
>
<Trash2 size={16} />
</IconButton>
{index < selectedRoles.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
} }
/> />
<Stack <Stack
@@ -153,19 +217,19 @@ const EditUserPage = () => {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
<DialogInput </form>
open={showDeleteDialog} <DialogInput
title={t("pages.editUser.dialog.removeUser.title")} open={showDeleteDialog}
content={t("pages.editUser.dialog.removeUser.content", { title={t("pages.editUser.dialog.removeUser.title")}
name: `${user?.firstName} ${user?.lastName}`, content={t("pages.editUser.dialog.removeUser.content", {
})} name: `${user?.firstName} ${user?.lastName}`,
onCancel={() => setShowDeleteDialog(false)} })}
onConfirm={handleDeleteUser} onCancel={() => setShowDeleteDialog(false)}
confirmText={t("common.buttons.removeUser")} onConfirm={handleDeleteUser}
loading={isDeleting} confirmText={t("common.buttons.removeUser")}
/> loading={isDeleting}
</BasePage> />
</FormProvider> </BasePage>
); );
}; };
+82 -61
View File
@@ -1,15 +1,13 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements"; import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button } from "@/Components/inputs";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { usePasswordForm } from "@/Hooks/usePasswordForm"; import { usePasswordForm } from "@/Hooks/usePasswordForm";
import { usePatch } from "@/Hooks/UseApi"; import { usePatch } from "@/Hooks/UseApi";
import type { PasswordFormData } from "@/Validation/password"; import type { PasswordFormData } from "@/Validation/password";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabPassword = () => { export const TabPassword = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,11 +15,10 @@ export const TabPassword = () => {
const { resolver, defaults } = usePasswordForm(); const { resolver, defaults } = usePasswordForm();
const { patch, loading } = usePatch<FormData, void>(); const { patch, loading } = usePatch<FormData, void>();
const form = useForm<PasswordFormData>({ const { control, handleSubmit, reset } = useForm<PasswordFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, reset } = form;
const onSubmit = async (data: PasswordFormData) => { const onSubmit = async (data: PasswordFormData) => {
const fd = new FormData(); const fd = new FormData();
@@ -35,61 +32,85 @@ export const TabPassword = () => {
}; };
return ( return (
<FormProvider {...form}> <Stack
<Stack gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.account.form.currentPassword.title")}
title={t("pages.account.form.currentPassword.title")} subtitle={t("pages.account.form.currentPassword.description")}
subtitle={t("pages.account.form.currentPassword.description")} rightContent={
rightContent={ <Controller
<FormTextField name="currentPassword"
name="currentPassword" control={control}
type="password" render={({ field, fieldState }) => (
autoComplete="current-password" <TextField
fieldLabel={t("pages.account.form.currentPassword.option.label")} {...field}
placeholder={t("pages.account.form.currentPassword.option.placeholder")} type="password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
autoComplete="current-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="newPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.account.form.newPassword.option.newPassword.label"
)}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/> />
} <Controller
/> name="confirm"
<ConfigBox control={control}
title={t("pages.account.form.newPassword.title")} render={({ field, fieldState }) => (
subtitle={t("pages.account.form.newPassword.description")} <TextField
rightContent={ {...field}
<Stack gap={theme.spacing(LAYOUT.MD)}> type="password"
<FormTextField fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
name="newPassword" placeholder={t(
type="password" "pages.account.form.newPassword.option.confirm.placeholder"
autoComplete="new-password" )}
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")} autoComplete="new-password"
placeholder={t( error={!!fieldState.error}
"pages.account.form.newPassword.option.newPassword.placeholder" helperText={fieldState.error?.message ?? ""}
)} />
/> )}
<FormTextField />
name="confirm" </Stack>
type="password" }
autoComplete="new-password" />
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")} <Button
placeholder={t( type="submit"
"pages.account.form.newPassword.option.confirm.placeholder" variant="contained"
)} color="primary"
/> loading={loading}
</Stack> sx={{ alignSelf: "flex-end", minWidth: 100 }}
} >
/> {t("common.buttons.save")}
<Button </Button>
type="submit" </Stack>
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
); );
}; };
+64 -52
View File
@@ -1,11 +1,11 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements"; import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button } from "@/Components/inputs";
import { ImageUpload } from "@/Components/inputs"; import { ImageUpload } from "@/Components/inputs";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { useProfileForm } from "@/Hooks/useProfileForm"; import { useProfileForm } from "@/Hooks/useProfileForm";
import { usePatch } from "@/Hooks/UseApi"; import { usePatch } from "@/Hooks/UseApi";
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
import type { ProfileFormData } from "@/Validation/profile"; import type { ProfileFormData } from "@/Validation/profile";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import type { User } from "@/Types/User"; import type { User } from "@/Types/User";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabProfile = () => { export const TabProfile = () => {
const theme = useTheme(); const theme = useTheme();
@@ -24,7 +22,7 @@ export const TabProfile = () => {
const { resolver, defaults } = useProfileForm(); const { resolver, defaults } = useProfileForm();
const { patch, loading: patchLoading } = usePatch<FormData, User>(); const { patch, loading: patchLoading } = usePatch<FormData, User>();
const form = useForm<ProfileFormData>({ const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
resolver, resolver,
defaultValues: { defaultValues: {
firstName: user?.firstName ?? defaults.firstName, firstName: user?.firstName ?? defaults.firstName,
@@ -33,7 +31,7 @@ export const TabProfile = () => {
deleteProfileImage: defaults.deleteProfileImage, deleteProfileImage: defaults.deleteProfileImage,
}, },
}); });
const { handleSubmit, setValue, watch } = form;
const currentImage = watch("profileImage"); const currentImage = watch("profileImage");
const deleteImage = watch("deleteProfileImage"); const deleteImage = watch("deleteProfileImage");
@@ -76,52 +74,66 @@ export const TabProfile = () => {
}; };
return ( return (
<FormProvider {...form}> <Stack
<Stack gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.account.form.name.title")}
title={t("pages.account.form.name.title")} subtitle={t("pages.account.form.name.description")}
subtitle={t("pages.account.form.name.description")} rightContent={
rightContent={ <Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(LAYOUT.MD)}> <Controller
<FormTextField name="firstName"
name="firstName" control={control}
autoComplete="given-name" render={({ field, fieldState }) => (
fieldLabel={t("common.form.name.option.firstName.label")} <TextField
placeholder={t("common.form.name.option.firstName.placeholder")} {...field}
/> fieldLabel={t("common.form.name.option.firstName.label")}
<FormTextField placeholder={t("common.form.name.option.firstName.placeholder")}
name="lastName" autoComplete="given-name"
autoComplete="family-name" error={!!fieldState.error}
fieldLabel={t("common.form.name.option.lastName.label")} helperText={fieldState.error?.message ?? ""}
placeholder={t("common.form.name.option.lastName.placeholder")} />
/> )}
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/> />
} <Controller
/> name="lastName"
<Button control={control}
type="submit" render={({ field, fieldState }) => (
variant="contained" <TextField
color="primary" {...field}
loading={patchLoading} fieldLabel={t("common.form.name.option.lastName.label")}
sx={{ alignSelf: "flex-end", minWidth: 100 }} placeholder={t("common.form.name.option.lastName.placeholder")}
> autoComplete="family-name"
{t("common.buttons.save")} error={!!fieldState.error}
</Button> helperText={fieldState.error?.message ?? ""}
</Stack> />
</FormProvider> )}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
); );
}; };
@@ -1,16 +1,15 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Dialog } from "@/Components/inputs"; import { Dialog, TextField, Select } from "@/Components/inputs";
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { User } from "@/Types/User"; import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
interface AddTeamMemberDialogProps { interface AddTeamMemberDialogProps {
open: boolean; open: boolean;
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
const { schema, defaults } = useAddTeamMemberForm(); const { schema, defaults } = useAddTeamMemberForm();
const { post, loading } = usePost<RegisterPayload, User>(); const { post, loading } = usePost<RegisterPayload, User>();
const form = useForm<AddTeamMemberFormData>({ const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
values: defaults, values: defaults,
}); });
const { handleSubmit, reset, setError } = form; const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => { const handleClose = () => {
reset(); reset();
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
const onSubmit = async (data: AddTeamMemberFormData) => { const onSubmit = async (data: AddTeamMemberFormData) => {
if (loading) return; if (loading) return;
const { confirm: _confirm, ...userData } = data; const { confirm, ...userData } = data;
const payload: RegisterPayload = userData; const payload: RegisterPayload = userData;
const result = await post("/auth/users", payload); const result = await post("/auth/users", payload);
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
}; };
return ( return (
<FormProvider {...form}> <Dialog
<Dialog open={open}
open={open} title={t("pages.account.team.addMember.title")}
title={t("pages.account.team.addMember.title")} content={t("pages.account.team.addMember.description")}
content={t("pages.account.team.addMember.description")} onCancel={handleClose}
onCancel={handleClose} onConfirm={handleSubmit(onSubmit)}
onConfirm={handleSubmit(onSubmit)} confirmText={t("common.buttons.addMember")}
confirmText={t("common.buttons.addMember")} loading={loading}
loading={loading} maxWidth="sm"
maxWidth="sm" fullWidth
fullWidth >
> <Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Stack
<Stack direction="row"
direction="row" gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(LAYOUT.XS)} >
> <Controller
<FormTextField name="firstName"
name="firstName" control={control}
fieldLabel={t("common.form.name.option.firstName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.firstName.placeholder")} <TextField
/> {...field}
<FormTextField fieldLabel={t("common.form.name.option.firstName.label")}
name="lastName" placeholder={t("common.form.name.option.firstName.placeholder")}
fieldLabel={t("common.form.name.option.lastName.label")} error={!!fieldState.error}
placeholder={t("common.form.name.option.lastName.placeholder")} helperText={fieldState.error?.message ?? ""}
/> sx={{ flex: 1 }}
</Stack> />
<FormTextField )}
name="email" />
type="email" <Controller
fieldLabel={t("common.form.email.option.email.label")} name="lastName"
placeholder={t("common.form.email.option.email.placeholder")} control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/> />
<RoleSelectField />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
sx={{ flex: 1 }}
/>
<FormTextField
name="confirm"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
/>
</Stack>
</Stack> </Stack>
</Dialog> <Controller
</FormProvider> name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
); );
}; };
@@ -1,16 +1,16 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { useTheme, Typography } from "@mui/material"; import MenuItem from "@mui/material/MenuItem";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Button } from "@/Components/inputs"; import { Dialog, TextField, Select, Button } from "@/Components/inputs";
import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm"; import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite"; import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig"; import { runtimeConfig } from "@/Utils/runtimeConfig";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
@@ -31,12 +31,11 @@ export const InviteTeamMemberDialog = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { resolver, defaults } = useInviteForm(); const { resolver, defaults } = useInviteForm();
const form = useForm<InviteFormData>({ const { control, handleSubmit, reset } = useForm<InviteFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
values: defaults, values: defaults,
}); });
const { handleSubmit, reset } = form;
const { post: generateToken, loading: generateLoading } = usePost< const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData, InviteFormData,
@@ -48,6 +47,12 @@ export const InviteTeamMemberDialog = ({
>(); >();
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => { const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data); const result = await generateToken("/invite", data);
if (result?.data?.token) { if (result?.data?.token) {
@@ -71,55 +76,88 @@ export const InviteTeamMemberDialog = ({
}; };
return ( return (
<FormProvider {...form}> <Dialog
<Dialog open={open}
open={open} title={t("pages.account.team.invite.title")}
title={t("pages.account.team.invite.title")} content={t("pages.account.team.invite.description")}
content={t("pages.account.team.invite.description")} onCancel={handleClose}
onCancel={handleClose} onConfirm={handleSubmit(handleSendInvite)}
onConfirm={handleSubmit(handleSendInvite)} confirmText={t("common.buttons.sendInvite")}
confirmText={t("common.buttons.sendInvite")} loading={sendLoading || generateLoading}
loading={sendLoading || generateLoading} maxWidth="sm"
maxWidth="sm" fullWidth
fullWidth additionalButtons={
additionalButtons={ <Button
<Button variant="contained"
variant="contained" color="primary"
color="primary" onClick={handleSubmit(handleGenerateToken)}
onClick={handleSubmit(handleGenerateToken)} loading={generateLoading || sendLoading}
loading={generateLoading || sendLoading} >
> {t("common.buttons.generateToken")}
{t("common.buttons.generateToken")} </Button>
</Button> }
} >
> <Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Controller
<FormTextField name="email"
name="email" control={control}
type="email" render={({ field, fieldState }) => (
fieldLabel={t("common.form.email.option.email.label")} <TextField
placeholder={t("common.form.email.option.email.placeholder")} {...field}
/> fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
<RoleSelectField /> type="email"
{inviteLink && ( fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<> <>
<Typography variant="body2"> <Select
{t("pages.account.team.invite.linkLabel")} {...field}
</Typography> value={Array.isArray(value) ? (value[0] ?? "") : ""}
<TextField onChange={(e) => onChange([e.target.value as UserRole])}
value={inviteLink} fieldLabel={t("common.form.role.option.role.label")}
fullWidth fullWidth
slotProps={{ error={!!fieldState.error}
input: { >
readOnly: true, {roleOptions.map((option) => (
}, <MenuItem
}} key={option.value}
/> value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</> </>
)} )}
</Stack> />
</Dialog> {inviteLink && (
</FormProvider> <>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
); );
}; };
@@ -1,45 +0,0 @@
import MenuItem from "@mui/material/MenuItem";
import FormHelperText from "@mui/material/FormHelperText";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Select } from "@/Components/inputs";
import { UserRoles } from "@/Types/User";
import type { UserRole } from "@/Types/User";
export const RoleSelectField = () => {
const { t } = useTranslation();
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
name: "role",
});
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
value: role,
label: t(`common.auth.roles.${role}`),
}));
return (
<>
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
);
};
+49 -38
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm"; import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login"; import type { LoginFormData } from "@/Validation/login";
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi"; import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const LoginPage = () => { const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,13 +31,11 @@ const LoginPage = () => {
const { schema, defaults } = useLoginForm(); const { schema, defaults } = useLoginForm();
const form = useForm<LoginFormData>({ const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit } = form;
if (isCheckingAdmin) return null; if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => { const onSubmit = async (data: LoginFormData) => {
@@ -53,39 +50,53 @@ const LoginPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.login.title")}
title={t("pages.auth.login.title")} subtitle={t("pages.auth.login.subtitle")}
subtitle={t("pages.auth.login.subtitle")} >
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
<FormTextField {t("pages.auth.login.submit")}
name="email" </Button>
fieldLabel={t("pages.auth.common.form.option.email.label")} <TextLink
placeholder={t("pages.auth.common.form.option.email.placeholder")} alignSelf={"center"}
/> text={t("pages.auth.login.links.forgotPassword.text")}
<FormTextField linkText={t("pages.auth.login.links.forgotPassword.linkText")}
name="password" href="/forgot-password"
type="password" />
fieldLabel={t("pages.auth.common.form.option.password.label")} </BaseAuthPage>
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+35 -31
View File
@@ -1,13 +1,12 @@
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery"; import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const ForgotPasswordPage = () => { const ForgotPasswordPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
const { schema, defaults } = useRecoveryForm(); const { schema, defaults } = useRecoveryForm();
const form = useForm<RecoveryFormData>({ const { control, handleSubmit } = useForm<RecoveryFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit } = form;
const onSubmit = async (data: RecoveryFormData) => { const onSubmit = async (data: RecoveryFormData) => {
if (loading) return; if (loading) return;
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.forgotPassword.title")}
title={t("pages.auth.forgotPassword.title")} subtitle={t("pages.auth.forgotPassword.subtitle")}
subtitle={t("pages.auth.forgotPassword.subtitle")} >
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
<FormTextField {t("pages.auth.forgotPassword.submit")}
name="email" </Button>
fieldLabel={t("pages.auth.common.form.option.email.label")} <TextLink
placeholder={t("pages.auth.common.form.option.email.placeholder")} alignSelf={"center"}
/> text={t("pages.auth.forgotPassword.links.login.text")}
<Button linkText={t("pages.auth.forgotPassword.links.login.linkText")}
variant="contained" href="/login"
type="submit" />
loading={loading} </BaseAuthPage>
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+103 -70
View File
@@ -1,7 +1,7 @@
import { BaseAuthPage } from "@/Components/design-elements"; import { BaseAuthPage } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert"; import Alert from "@mui/material/Alert";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm"; import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register"; import type { RegisterFormData } from "@/Validation/register";
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User"; import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
interface RegisterPayload { interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">; user: Omit<RegisterFormData, "confirm">;
@@ -46,11 +45,10 @@ const RegisterPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const form = useForm<RegisterFormData>({ const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, setError, reset } = form;
useEffect(() => { useEffect(() => {
if (!token || hasVerified.current) return; if (!token || hasVerified.current) return;
@@ -66,14 +64,14 @@ const RegisterPage = () => {
navigate("/register", { replace: true }); navigate("/register", { replace: true });
} }
}); });
}, [token, defaults, navigate, reset, verifyToken]); }, [token]);
if (isCheckingAdmin) return null; if (isCheckingAdmin) return null;
const onSubmit = async (data: RegisterFormData) => { const onSubmit = async (data: RegisterFormData) => {
if (loading) return; if (loading) return;
const { confirm: _confirm, ...userData } = data; const { confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData }; const payload: RegisterPayload = { user: userData };
if (token) { if (token) {
payload.token = token; payload.token = token;
@@ -91,70 +89,105 @@ const RegisterPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.register.title")}
title={t("pages.auth.register.title")} subtitle={t("pages.auth.register.subtitle")}
subtitle={t("pages.auth.register.subtitle")} >
> {!token && (
{!token && ( <Alert
<Alert severity="info"
severity="info" icon={false}
icon={false} sx={(theme) => ({
sx={(theme) => ({ fontSize: 13,
fontSize: 13, lineHeight: 1.55,
lineHeight: 1.55, color: theme.palette.text.secondary,
color: theme.palette.text.secondary, backgroundColor: theme.palette.action.hover,
backgroundColor: theme.palette.action.hover, border: `1px solid ${theme.palette.divider}`,
border: `1px solid ${theme.palette.divider}`, borderRadius: 1,
borderRadius: 1, "& .MuiAlert-message": { padding: 0 },
"& .MuiAlert-message": { padding: 0 }, })}
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<FormTextField
name="email"
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
{t("pages.auth.register.submit")} {t("pages.auth.register.setupNotice")}
</Button> </Alert>
</BaseAuthPage> )}
</FormProvider> <Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
); );
}; };
+78 -66
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm"; import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword"; import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const SetNewPasswordPage = () => { const SetNewPasswordPage = () => {
const theme = useTheme(); const theme = useTheme();
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
const { schema, defaults } = useSetNewPasswordForm(); const { schema, defaults } = useSetNewPasswordForm();
const form = useForm<SetNewPasswordFormData>({ const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, watch } = form;
const password = watch("password"); const password = watch("password");
const confirm = watch("confirm"); const confirm = watch("confirm");
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.setNewPassword.title")}
title={t("pages.auth.setNewPassword.title")} subtitle={t("pages.auth.setNewPassword.subtitle")}
subtitle={t("pages.auth.setNewPassword.subtitle")} >
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
> >
<FormTextField {t("common.buttons.resetPassword")}
name="password" </Button>
type="password" <TextLink
fieldLabel={t("pages.auth.common.form.option.password.label")} alignSelf="center"
placeholder={t("pages.auth.common.form.option.password.placeholder")} text={t("pages.auth.forgotPassword.links.login.text")}
/> linkText={t("pages.auth.forgotPassword.links.login.linkText")}
<FormTextField href="/login"
name="confirm" />
type="password" </BaseAuthPage>
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+1 -2
View File
@@ -17,14 +17,13 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi"; import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check"; import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
import type { DateRange } from "@/Types/Query";
const Checks = () => { const Checks = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>(); const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0"); const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down"); const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10); const [rowsPerPage, setRowsPerPage] = useState<number>(10);
File diff suppressed because it is too large. Load diff
Whitespace-only changes.
@@ -1,51 +0,0 @@
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";
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();
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("common.labels.state")}
subtitle={state}
/>
<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>
);
};
@@ -1,3 +0,0 @@
export const TabLogs = () => {
return "This is the logs tab";
};
@@ -1,113 +0,0 @@
// 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>
);
};
@@ -1,78 +0,0 @@
// Components
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
// Hooks
import { useParams } from "react-router-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { DockerContainerResponse } from "@/Types/Monitor";
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
import type { DateRange } from "@/Types/Query";
import { HeaderTimeRange } from "@/Components/common";
interface DockerContainerDetailsProps {
open?: "overview" | "logs";
}
const TAB_MAP = {
overview: 0,
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
monitorId: string;
containerName: string;
}>();
const { t } = useTranslation();
const containerDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
: null;
const { data: containerData } = useGet<DockerContainerResponse>(
containerDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = containerData?.monitor;
if (!monitor) return null;
const stats = containerData.stats;
const container = stats?.latest?.container;
if (!container) return null;
const { state, name, image } = container;
return (
<BasePage headerKey="docker.container">
<DockerStateLabel
state={state}
name={name}
image={image}
/>
<DockerContainerStatusBoxes container={container} />
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 1 && <TabLogs />}
</BasePage>
);
};
export default DockerDetailsPage;
@@ -1,123 +0,0 @@
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>
);
};
@@ -1,47 +0,0 @@
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>
);
};
@@ -1,55 +0,0 @@
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;
@@ -1,347 +0,0 @@
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}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={monitors.length}
/>
</Box>
);
};
@@ -1,44 +0,0 @@
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;
+1 -2
View File
@@ -20,7 +20,6 @@ import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident"; import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { DateRange } from "@/Types/Query";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
const IncidentsPage = () => { const IncidentsPage = () => {
@@ -31,7 +30,7 @@ const IncidentsPage = () => {
// Filter state // Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10); const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0); const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
+5 -2
View File
@@ -1,5 +1,5 @@
import type { Incident, IncidentSummaryItem } from "@/Types/Incident"; import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
import { formatDuration } from "@/Utils/TimeUtils"; import prettyMilliseconds from "pretty-ms";
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">; type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
@@ -24,5 +24,8 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
return "-"; return "-";
} }
return formatDuration(durationMs); return prettyMilliseconds(durationMs, {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
});
}; };
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => { const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [ return [
{ {
title: t("common.charts.labels.memoryUsage"), title: t("pages.infrastructure.charts.labels.memory"),
type: "memory", type: "memory",
dataKeys: ["avgMemoryUsage"], dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main, strokeColor: theme.palette.primary.main,
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
idx: null, idx: null,
}, },
{ {
title: t("common.charts.labels.cpuUsage"), title: t("pages.infrastructure.charts.labels.cpu"),
type: "cpu", type: "cpu",
dataKeys: ["avgCpuUsage"], dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main, strokeColor: theme.palette.success.main,
@@ -63,42 +63,19 @@ const getChartConfigs = (
return configs; return configs;
}; };
// Loopback / bridge names Capture reports when it can only observe the // Loopback / bridge names Capture sees when it can only observe the container's
// container's own network namespace. // own network namespace. When ALL reported interfaces match this set we assume
// the "no real interfaces visible" case and surface the notice.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]); 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 onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set<string>(); const named = new Set<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name))); checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) { if (named.size === 0) return false;
return false; for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) 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 = ({ export const InfraNetworkCharts = ({
@@ -8,8 +8,6 @@ import { useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import type { CheckSnapshot } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
const GAUGE_MAX_WIDTH = 260;
export const InfraDetailsGauges = ({ export const InfraDetailsGauges = ({
snapshot, snapshot,
}: { }: {
@@ -27,43 +25,35 @@ export const InfraDetailsGauges = ({
<Stack <Stack
direction={isSmall ? "column" : "row"} direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)} spacing={theme.spacing(8)}
alignItems={"stretch"} alignItems={"center"}
flexWrap={"wrap"}
useFlexGap
> >
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.memory.title")} title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={GAUGE_MAX_WIDTH}
progress={(snapshot?.memory?.usage_percent || 0) * 100} progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")} upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)} upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")} lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)} lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/> />
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")} title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={GAUGE_MAX_WIDTH}
progress={(snapshot?.cpu?.usage_percent || 0) * 100} progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")} upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)} upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")} lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)} lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/> />
{snapshot?.disk?.map((disk, idx) => { {snapshot?.disk?.map((disk, idx) => {
return ( return (
<DetailGauge <DetailGauge
key={disk?.device || `disk-${idx}`} key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`} // title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })} title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={GAUGE_MAX_WIDTH}
progress={(disk.usage_percent || 0) * 100} progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")} upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)} upperValue={prettyBytes(disk?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")} lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
lowerValue={prettyBytes(disk?.total_bytes || 0)} lowerValue={prettyBytes(disk?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/> />
); );
})} })}
@@ -11,8 +11,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const recentChecks = monitor?.recentChecks ?? []; const latestCheck = monitor?.recentChecks?.[0];
const latestCheck = recentChecks[recentChecks.length - 1];
// Get data from latest check // Get data from latest check
const physicalCores = getCores(latestCheck?.cpu?.physical_core); const physicalCores = getCores(latestCheck?.cpu?.physical_core);
const logicalCores = getCores(latestCheck?.cpu?.logical_core); const logicalCores = getCores(latestCheck?.cpu?.logical_core);
@@ -22,7 +21,13 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes = const diskTotalBytes =
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0; latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
const osPlatform = getOsAndPlatform(latestCheck?.host); const os = getOsAndPlatform(latestCheck?.host);
const platform = latestCheck?.host?.platform ?? undefined;
const osPlatform =
typeof os === "undefined" && typeof platform === "undefined"
? undefined
: `${os} ${platform}`;
return ( return (
<Stack <Stack
@@ -21,11 +21,10 @@ export const TabOverview = ({
} }
const checks = stats?.checks || []; const checks = stats?.checks || [];
const recentChecks = monitor.recentChecks ?? [];
return ( return (
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<StatusBoxes monitor={monitor} /> <StatusBoxes monitor={monitor} />
<InfraDetailsGauges snapshot={recentChecks[recentChecks.length - 1]} /> <InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
<InfraDetailsCharts <InfraDetailsCharts
checks={checks} checks={checks}
dateRange={dateRange} dateRange={dateRange}
@@ -10,7 +10,6 @@ import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor"; import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DateRange } from "@/Types/Query";
const InfrastructureDetails = () => { const InfrastructureDetails = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +17,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>(); const { monitorId } = useParams<{ monitorId: string }>();
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [selectedTab, setSelectedTab] = useState<number>(0); const [selectedTab, setSelectedTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
@@ -248,8 +248,7 @@ export const InfraMonitorsTable = ({
id: "cpu", id: "cpu",
content: t("pages.infrastructure.table.headers.cpu"), content: t("pages.infrastructure.table.headers.cpu"),
render: (row) => { render: (row) => {
const recentChecks = row.recentChecks ?? []; const check = row.recentChecks?.[0];
const check = recentChecks[recentChecks.length - 1];
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100; const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
return <Gauge progress={cpuUsage} />; return <Gauge progress={cpuUsage} />;
}, },
@@ -258,8 +257,7 @@ export const InfraMonitorsTable = ({
id: "memory", id: "memory",
content: t("pages.infrastructure.table.headers.memory"), content: t("pages.infrastructure.table.headers.memory"),
render: (row) => { render: (row) => {
const recentChecks = row.recentChecks ?? []; const check = row.recentChecks?.[0];
const check = recentChecks[recentChecks.length - 1];
const memoryUsage = (check?.memory?.usage_percent || 0) * 100; const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
return <Gauge progress={memoryUsage} />; return <Gauge progress={memoryUsage} />;
}, },
@@ -268,8 +266,7 @@ export const InfraMonitorsTable = ({
id: "disk", id: "disk",
content: t("pages.infrastructure.table.headers.disk"), content: t("pages.infrastructure.table.headers.disk"),
render: (row) => { render: (row) => {
const recentChecks = row.recentChecks ?? []; const check = row.recentChecks?.[0];
const check = recentChecks[recentChecks.length - 1];
const totalDiskUsage = check?.disk?.reduce( const totalDiskUsage = check?.disk?.reduce(
(acc, disk) => acc + (disk?.usage_percent || 0), (acc, disk) => acc + (disk?.usage_percent || 0),
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors"; import Stack from "@mui/material/Stack";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController"; import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => { const InfrastructureMonitors = () => {
const c = useMonitorListController({ const { t } = useTranslation();
types: ["hardware"], const theme = useTheme();
checksLimit: 1, const isSmall = useMediaQuery(theme.breakpoints.down("md"));
refreshInterval: 5000, const isAdmin = useIsAdmin();
rowsPerPageTable: "infrastructure", const dispatch = useDispatch();
rowsPerPageDefault: 5,
}); // Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="infrastructure" headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure" page="infrastructure"
actionLink="/infrastructure/create" actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
> >
<InfraMonitorsTable <HeaderCreate
monitors={c.monitors || []} path="/infrastructure/create"
tags={c.tags || []} isLoading={isLoading}
refetch={c.refetch} isAdmin={isAdmin}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
/> />
</MonitorListPage> <HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
+9 -5
View File
@@ -2,10 +2,10 @@ import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements"; import { StatBox } from "@/Components/design-elements";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics"; import type { Diagnostics } from "@/Types/Diagnostics";
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
interface StatsProps { interface StatsProps {
diagnostics: Diagnostics | null; diagnostics: Diagnostics | null;
@@ -18,9 +18,13 @@ export const Stats = ({ diagnostics }: StatsProps) => {
const theme = useTheme(); const theme = useTheme();
const eventLoopDelay = diagnostics const eventLoopDelay = diagnostics
? formatMs(diagnostics.eventLoopDelayMs ?? 0) ? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})
: PLACEHOLDER;
const uptime = diagnostics
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
: PLACEHOLDER; : PLACEHOLDER;
const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER;
const usedHeap = diagnostics const usedHeap = diagnostics
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0) ? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
: PLACEHOLDER; : PLACEHOLDER;
@@ -33,10 +37,10 @@ export const Stats = ({ diagnostics }: StatsProps) => {
return ( return (
<Box <Box
display="grid"
gap={theme.spacing(8)}
sx={{ sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" }, gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: theme.spacing(8),
"& > *": { width: "100% !important" }, "& > *": { width: "100% !important" },
}} }}
> >
@@ -1,6 +1,7 @@
import { Table, Pagination } from "@/Components/design-elements"; import { Table, Pagination } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material"; import { Typography, useTheme } from "@mui/material";
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils"; import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
@@ -83,7 +84,7 @@ export const TableJobs = ({
id: "interval", id: "interval",
content: t("common.table.headers.interval"), content: t("common.table.headers.interval"),
render: (row) => ( render: (row) => (
<Typography sx={cellSx}>{formatDuration(row.monitorInterval ?? 0)}</Typography> <Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
), ),
}, },
{ {
@@ -4,6 +4,7 @@ import { Pagination } from "@/Components/design-elements/Table";
import { ActionsMenu } from "@/Components/actions-menu"; import { ActionsMenu } from "@/Components/actions-menu";
import { DialogInput } from "@/Components/inputs/Dialog"; import { DialogInput } from "@/Components/inputs/Dialog";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import type { ActionMenuItem } from "@/Components/actions-menu"; import type { ActionMenuItem } from "@/Components/actions-menu";
@@ -17,7 +18,6 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useState } from "react";
import { useDelete, usePatch } from "@/Hooks/UseApi"; import { useDelete, usePatch } from "@/Hooks/UseApi";
import { formatDuration } from "@/Utils/TimeUtils";
interface MaintenanceWindowTableProps { interface MaintenanceWindowTableProps {
maintenanceWindows: MaintenanceWindow[]; maintenanceWindows: MaintenanceWindow[];
@@ -51,7 +51,7 @@ const getTimeToNextWindow = (
// Window is in the future // Window is in the future
if (start.isAfter(now)) { if (start.isAfter(now)) {
return formatDuration(start.diff(now)); return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
} }
return "N/A"; return "N/A";
@@ -152,7 +152,9 @@ export const MaintenanceWindowTable = ({
id: "repeat", id: "repeat",
content: t("pages.maintenanceWindow.table.headers.repeat"), content: t("pages.maintenanceWindow.table.headers.repeat"),
render: (row) => render: (row) =>
row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat), row.repeat === 0
? t("common.labels.na")
: prettyMilliseconds(row.repeat, { verbose: true }),
}, },
{ {
id: "actions", id: "actions",
+259 -141
View File
@@ -1,9 +1,18 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import {
TextField,
Select,
DatePicker,
TimePicker,
Button,
Autocomplete,
} from "@/Components/inputs";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -16,18 +25,10 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr"; import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import { useForm, Controller } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { useEffect } from "react"; import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { Trash2 } from "lucide-react";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormDatePickerField,
FormTimePickerField,
} from "@/Components/inputs/forms/FormDateTimePickerField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const CreateMaintenanceWindowPage = () => { const CreateMaintenanceWindowPage = () => {
const theme = useTheme(); const theme = useTheme();
@@ -43,7 +44,6 @@ const CreateMaintenanceWindowPage = () => {
); );
const { data: monitors } = useGet<Monitor[]>("/monitors/team"); const { data: monitors } = useGet<Monitor[]>("/monitors/team");
const { data: tags } = useGet<Tag[]>("/tags/team");
const { post, loading: isPosting } = usePost(); const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch(); const { patch, loading: isPatching } = usePatch();
@@ -57,7 +57,7 @@ const CreateMaintenanceWindowPage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, trigger } = form; const { control, handleSubmit, trigger } = form;
useEffect(() => { useEffect(() => {
if (existingMaintenanceWindow) { if (existingMaintenanceWindow) {
form.reset(defaults); form.reset(defaults);
@@ -114,137 +114,255 @@ const CreateMaintenanceWindowPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage
<BasePage component={"form"}
component={"form"} onSubmit={handleSubmit(onSubmit, onError)}
onSubmit={handleSubmit(onSubmit, onError)} >
> <ConfigBox
<ConfigBox title={t("pages.maintenanceWindow.form.general.title")}
title={t("pages.maintenanceWindow.form.general.title")} subtitle={t("pages.maintenanceWindow.form.general.description")}
subtitle={t("pages.maintenanceWindow.form.general.description")} rightContent={
rightContent={ <Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Controller
<FormTextField name="name"
name="name" control={control}
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")} defaultValue={defaults.name}
placeholder={t( render={({ field, fieldState }) => (
"pages.maintenanceWindow.form.general.option.name.placeholder" <TextField
)} {...field}
/> type="text"
<FormSelectField fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
name="repeat" placeholder={t(
fieldLabel={t("pages.maintenanceWindow.form.general.option.repeat.label")} "pages.maintenanceWindow.form.general.option.name.placeholder"
options={repeatOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormDatePickerField
name="startDate"
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
onValueChange={() => trigger("startDate")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTimePickerField
name="startTime"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
onValueChange={() => trigger("startDate")}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<FormNumberField
name="duration"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)} )}
sx={{ width: 120 }} fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/> />
<FormSelectField
name="durationUnit"
fieldLabel={" "}
options={durationUnitOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<FormMultiSelectField
name="monitors"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
placeholder={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
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>
)} )}
/> />
} <Controller
/> name="repeat"
<Stack control={control}
direction="row" defaultValue={defaults.repeat}
justifyContent="flex-end" render={({ field, fieldState }) => (
spacing={theme.spacing(SPACING.LG)} <Select
value={field.value}
fieldLabel={t(
"pages.maintenanceWindow.form.general.option.repeat.label"
)}
error={!!fieldState.error}
onChange={field.onChange}
>
{repeatOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startDate"
control={control}
defaultValue={defaults.startDate}
render={({ field, fieldState }) => (
<DatePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
onChange={(date) => {
field.onChange(date ? date.format("YYYY-MM-DD") : "");
trigger("startDate");
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startTime"
control={control}
defaultValue={defaults.startTime}
render={({ field }) => (
<TimePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
value={field.value ? dayjs(field.value, "HH:mm") : null}
onChange={(time) => {
field.onChange(time ? time.format("HH:mm") : "");
trigger("startDate");
}}
/>
)}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<Controller
name="duration"
control={control}
defaultValue={defaults.duration}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)}
value={field.value === 0 ? "" : field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ width: 120 }}
/>
)}
/>
<Controller
name="durationUnit"
control={control}
defaultValue={defaults.durationUnit}
render={({ field }) => (
<Select
fieldLabel=" "
value={field.value}
onChange={field.onChange}
sx={{ minWidth: 120 }}
>
{durationUnitOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
defaultValue={defaults.monitors}
render={({ field, fieldState }) => {
const monitorsList = monitors ?? [];
const selectedMonitors = field.value
.map((id: string) => monitorsList.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitorsList}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<Stack>
{selectedMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter((id: string) => id !== monitor.id)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
> >
<Button {t("common.buttons.save")}
loading={isLoading} </Button>
type="submit" </Stack>
variant="contained" </BasePage>
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
); );
}; };
+381 -192
View File
@@ -1,12 +1,14 @@
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Select, Button } from "@/Components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FormProvider, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useNotificationForm } from "@/Hooks/useNotificationForm"; import { useNotificationForm } from "@/Hooks/useNotificationForm";
@@ -14,8 +16,6 @@ import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification"; import type { Notification } from "@/Types/Notification";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NotificationChannels } from "@/Types/Notification"; import { NotificationChannels } from "@/Types/Notification";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
const NotificationsCreatePage = () => { const NotificationsCreatePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,7 +39,7 @@ const NotificationsCreatePage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
useEffect(() => { useEffect(() => {
reset(defaults); reset(defaults);
@@ -93,195 +93,384 @@ const NotificationsCreatePage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage
<BasePage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
<ConfigBox
title={t("pages.notifications.form.notificationName.title")}
subtitle={t("pages.notifications.form.notificationName.description")}
rightContent={
<Controller
name="notificationName"
control={control}
defaultValue={defaults.notificationName}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
defaultValue={defaults.type}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t("pages.notifications.form.type.optionType")}
error={!!fieldState.error}
onChange={field.onChange}
>
{NotificationChannels.map((type: string) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform="capitalize">{type}</Typography>
</MenuItem>
))}
</Select>
)}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="topic"
control={control}
defaultValue={"topic" in defaults ? defaults.topic : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="homeserverUrl"
control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="roomId"
control={control}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t(
"pages.notifications.form.accessToken.optionAccessToken"
)}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
> >
<ConfigBox <Button
title={t("pages.notifications.form.notificationName.title")} variant="contained"
subtitle={t("pages.notifications.form.notificationName.description")} color="primary"
rightContent={ onClick={handleTest}
<FormTextField loading={isTesting}
name="notificationName"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<FormSelectField
name="type"
fieldLabel={t("pages.notifications.form.type.optionType")}
options={NotificationChannels.map((channel) => ({
value: channel,
label: t(`pages.notifications.form.type.value.${channel}`),
}))}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<FormTextField
name="address"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
/>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
/>
<FormTextField
name="topic"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t("pages.notifications.form.telegram.placeholderBotToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t("pages.notifications.form.pushover.placeholderAppToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accountSid"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t("pages.notifications.form.twilio.placeholderAccountSid")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t("pages.notifications.form.twilio.placeholderAuthToken")}
/>
<FormTextField
name="twilioPhoneNumber"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderFromNumber")}
/>
<FormTextField
name="phone"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="homeserverUrl"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
/>
<FormTextField
name="roomId"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.accessToken.optionAccessToken")}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
> >
<Button {t("common.buttons.test")}
variant="contained" </Button>
color="primary" <Button
onClick={handleTest} loading={isSubmitting || isPatching}
loading={isTesting} type="submit"
> variant="contained"
{t("common.buttons.test")} color="primary"
</Button> >
<Button {t("common.buttons.save")}
loading={isSubmitting || isPatching} </Button>
type="submit" </Stack>
variant="contained" </BasePage>
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
); );
}; };
+3 -6
View File
@@ -15,13 +15,12 @@ import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import type { DateRange } from "@/Types/Query";
const PageSpeedDetails = () => { const PageSpeedDetails = () => {
const { monitorId } = useParams(); const { monitorId } = useParams();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const theme = useTheme(); const theme = useTheme();
const [dateRange, setDateRange] = useState<DateRange>("day"); const [dateRange, setDateRange] = useState<string>("day");
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
if (!monitorId) { if (!monitorId) {
@@ -46,8 +45,6 @@ const PageSpeedDetails = () => {
const monitor = monitorData?.monitorData?.monitor; const monitor = monitorData?.monitorData?.monitor;
const groupedChecks = monitorData?.monitorData?.groupedChecks || []; const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
const monitorStats = monitorData?.monitorStats || null; const monitorStats = monitorData?.monitorStats || null;
const recentChecks = monitor?.recentChecks ?? [];
const latestCheck = recentChecks[recentChecks.length - 1];
return ( return (
<BasePage <BasePage
@@ -78,8 +75,8 @@ const PageSpeedDetails = () => {
direction={{ xs: "column", md: "row" }} direction={{ xs: "column", md: "row" }}
gap={theme.spacing(10)} gap={theme.spacing(10)}
> >
<PiePageSpeed latestCheck={latestCheck} /> <PiePageSpeed latestCheck={monitor?.recentChecks?.[0]} />
<PiePageSpeedLegend latestCheck={latestCheck} /> <PiePageSpeedLegend latestCheck={monitor?.recentChecks?.[0]} />
</Stack> </Stack>
</BasePage> </BasePage>
); );
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements"; import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi"; import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings"; import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors"; import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
const PageSpeedMonitorsPage = () => { const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const { const {
data: settingsData, data: settingsData,
isLoading: settingsIsLoading, isLoading: settingsIsLoading,
error: settingsError, error: settingsError,
} = useGet<AppSettingsResponse>("/settings"); } = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({ const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
types: ["pagespeed"], const isDialogOpen = Boolean(selectedMonitor);
checksLimit: 25, const [sortField, setSortField] = useState<string>("name");
refreshInterval: 30000, const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
rowsPerPageTable: "pagespeed", const [page, setPage] = useState(0);
rowsPerPageDefault: 10, const [rowsPerPage, setRowsPerPage] = useState(10);
initialSortField: "name", const [selectedTags, setSelectedTags] = useState<string[]>([]);
}); const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet; const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="pageSpeed" headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed" page="pageSpeed"
actionLink="/pagespeed/create" actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined} priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
> >
<PageSpeedMonitorsTable <HeaderCreate
monitors={c.monitors || []} path="/pagespeed/create"
tags={c.tags || []} isLoading={isLoading}
refetch={c.refetch} isAdmin={isAdmin}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
/> />
</MonitorListPage> <HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
@@ -1,103 +0,0 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { ProxyResponse } from "@/Types/Proxy";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface ProxiesTableProps {
proxies: ProxyResponse[];
setSelectedProxy: Function;
}
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(proxies);
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/proxies/configure/${proxy.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedProxy(proxy);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<ProxyResponse>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "protocol",
content: t("pages.proxies.table.headers.protocol"),
render: (row) => {
return <Typography>{row?.protocol}</Typography>;
},
},
{
id: "host",
content: t("pages.proxies.table.headers.host"),
render: (row) => {
return <Typography>{row?.host}</Typography>;
},
},
{
id: "port",
content: t("pages.proxies.table.headers.port"),
render: (row) => {
return <Typography>{row?.port}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/proxies/configure/${row.id}`);
}}
/>
{proxies.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff. Show more