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
307 changed files with 10047 additions and 14750 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
+138
View File
@@ -0,0 +1,138 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+113
View File
@@ -0,0 +1,113 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+1
View File
@@ -51,6 +51,7 @@ TOKEN_TTL="99d"
ORIGIN="localhost" ORIGIN="localhost"
LOG_LEVEL="debug" LOG_LEVEL="debug"
``` ```
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
### Client `.env` ### Client `.env`
```env ```env
+7 -5
View File
@@ -124,10 +124,12 @@ LOG_LEVEL="debug"
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173) - `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure) - `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL - `DB_CONNECTION_STRING`: MongoDB connection URL
- `ORIGIN`: Origin for CORS purposes - `ORIGIN`: Origin for CORS purposes (optional)
- `TOKEN_TTL`: Token time to live (in vercel/ms format) - `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`)
- `LOG_LEVEL`: Debug level (debug, info, warn, error) - `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`)
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`) - `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
Start the backend server: Start the backend server:
@@ -203,7 +205,7 @@ docker rm uptime_database_mongo
**Port already in use:** **Port already in use:**
- Check if another service is using ports 5173, 52345, 27017, or 6379 - Check if another service is using ports 5173, 52345, or 27017
- Stop the conflicting service or change the port in `.env` files - Stop the conflicting service or change the port in `.env` files
**MongoDB connection issues:** **MongoDB connection issues:**
+6 -13
View File
@@ -32,13 +32,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1> <h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong> <p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" /> <img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br> <br>
@@ -81,9 +80,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a> <a id="installation"></a>
## Installation ## Installation
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service. The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
```bash ```bash
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
@@ -92,10 +89,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345. Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### Configuration ### Configuration
The image is configured entirely through environment variables on the server container: The image is configured entirely through environment variables on the server container:
@@ -158,7 +151,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Selective disk monitoring with mountpoint selection - Selective disk monitoring with mountpoint selection
- Incidents at a glance - Incidents at a glance
- Status pages with 4 beautiful themes - Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance - Scheduled maintenance
- JSON query monitoring - JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese - Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
@@ -212,9 +205,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a> <a id="contributing"></a>
## Contributing ## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe. We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us! Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "client", "name": "client",
"version": "3.11.0", "version": "3.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "client", "name": "client",
"version": "3.11.0", "version": "3.9.2",
"dependencies": { "dependencies": {
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
+1 -2
View File
@@ -1,10 +1,9 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.11.0", "version": "3.9.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development", "build-dev": "tsc -b && vite build --mode development",
@@ -1,7 +1,7 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { CheckSnapshot } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
</Typography> </Typography>
<Typography> <Typography>
{t("common.labels.responseTime")}:{" "} {t("common.labels.responseTime")}:{" "}
{check?.responseTime ? formatMs(check.responseTime) : "N/A"} {check?.originalResponseTime?.toFixed() ?? "N/A"} ms
</Typography> </Typography>
<Typography textTransform={"capitalize"}> <Typography textTransform={"capitalize"}>
Status:{" "} Status:{" "}
@@ -8,8 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
import { formatMs } from "@/Utils/TimeUtils";
interface HistogramResponseTimeProps { interface HistogramResponseTimeProps {
checks: CheckSnapshot[]; checks: CheckSnapshot[];
@@ -20,25 +18,25 @@ interface HistogramResponseTimeProps {
} }
interface ResponseTimeStats { interface ResponseTimeStats {
max: string | null; max: number | string;
avg: string | null; avg: number | string;
} }
const DEFAULT_HEIGHT = 50; const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) { if (!Array.isArray(checks) || checks.length === 0) {
return { max: null, avg: null }; return { max: "-", avg: "-" };
} }
const validChecks = checks.filter((check) => check.responseTime != null); const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) { if (validChecks.length === 0) {
return { max: null, avg: null }; return { max: "-", avg: "-" };
} }
const responseTimes = validChecks.map((check) => check.responseTime); const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = formatMs(Math.max(...responseTimes)); const max = Math.round(Math.max(...responseTimes));
const avg = formatMs( const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
); );
@@ -56,7 +54,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation(); const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]); const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null; if (!Array.isArray(checks) || checks.length === 0) return null;
const data = const data =
@@ -70,28 +68,29 @@ export const HistogramResponseTime = ({
const chartHeight = typeof height === "number" ? `${height}px` : height; const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5); const gridGap = gap ?? theme.spacing(0.5);
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && ( const statsContent = showStats &&
<Stack (typeof stats.max === "number" || typeof stats.avg === "number") && (
justifyContent="center" <Stack
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"} justifyContent="center"
width={110} alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
pr={statsPosition === "left" ? theme.spacing(8) : 0} width={110}
pl={statsPosition === "right" ? theme.spacing(8) : 0} pr={statsPosition === "left" ? theme.spacing(8) : 0}
> pl={statsPosition === "right" ? theme.spacing(8) : 0}
<Typography
variant="body2"
noWrap
> >
{t("common.charts.histogram.avg", { value: stats.avg })} <Typography
</Typography> variant="body2"
<Typography noWrap
variant="body2" >
noWrap {t("common.charts.histogram.avg", { value: stats.avg })}
> </Typography>
{t("common.charts.histogram.max", { value: stats.max })} <Typography
</Typography> variant="body2"
</Stack> noWrap
); >
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return ( return (
<Stack <Stack
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
> >
{data.map((check, index) => { {data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder"; const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${barHeights[index] ?? 0}%`; const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor = const barColor =
check.status === true check.status === true
? theme.palette.success.light ? theme.palette.success.light
@@ -13,7 +13,6 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string; width?: number | string;
maxWidth?: number | string; maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>; padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>; }>;
export const BaseChart = ({ export const BaseChart = ({
@@ -22,22 +21,16 @@ export const BaseChart = ({
width = "100%", width = "100%",
maxWidth = "100%", maxWidth = "100%",
padding, padding,
onClick,
}: BaseChartProps) => { }: BaseChartProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<BaseBox <BaseBox
onClick={onClick}
sx={{ sx={{
padding: padding ?? theme.spacing(LAYOUT.MD), padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex", display: "flex",
flex: 1, flex: 1,
width: width, width: width,
maxWidth: maxWidth, maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}} }}
> >
<Stack <Stack
@@ -47,7 +40,6 @@ export const BaseChart = ({
<Typography <Typography
variant="eyebrow" variant="eyebrow"
color="text.secondary" color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
> >
{title} {title}
</Typography> </Typography>
+4 -14
View File
@@ -1,28 +1,18 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps { interface DotProps {
color?: string; color?: string;
size?: DotSize; size?: string;
style?: CSSProperties; style?: CSSProperties;
} }
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => { export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return ( return (
<span <span
style={{ style={{
content: '""', content: '""',
width: DOT_SIZE_PX[size], width: size,
height: DOT_SIZE_PX[size], height: size,
borderRadius: "50%", borderRadius: "50%",
backgroundColor: color, backgroundColor: color,
opacity: 0.8, opacity: 0.8,
@@ -5,15 +5,13 @@ interface IconProps {
size?: number; size?: number;
strokeWidth?: number; strokeWidth?: number;
stroke?: string; stroke?: string;
color?: string;
} }
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => { const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
return ( return (
<Icon <Icon
size={size} size={size}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
color={color}
/> />
); );
}; };
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette"; import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<> <>
<Dot /> <Dot />
<Typography> <Typography>
Checking every {formatDuration(monitor?.interval, true)} Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography> </Typography>
</> </>
)} )}
+1 -11
View File
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type AutoCompleteInputProps = Omit< type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>, AutocompleteProps<any, boolean, boolean, boolean>,
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode; renderOptionContent?: (option: any) => React.ReactNode;
placeholder?: string;
error?: boolean;
helperText?: string;
}; };
export const AutoCompleteInput = ({ export const AutoCompleteInput = ({
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
required, required,
renderInput, renderInput,
renderOptionContent, renderOptionContent,
placeholder,
error,
helperText,
...props ...props
}: AutoCompleteInputProps) => { }: AutoCompleteInputProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation();
const multiple = props.multiple; const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props; const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => ( const defaultRenderInput = (params: any) => (
<TextField <TextField
{...params} {...params}
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")} placeholder="Type to search"
error={error}
helperText={helperText}
/> />
); );
-3
View File
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
"& .MuiButtonBase-root": { "& .MuiButtonBase-root": {
mr: theme.spacing(6), mr: theme.spacing(6),
}, },
"&:has(.Mui-focusVisible)": {
backgroundColor: theme.palette.action.hover,
},
}} }}
/> />
); );
+4 -15
View File
@@ -11,11 +11,10 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
placeholder?: string; placeholder?: string;
helperText?: string;
} }
const SelectInputInner = <T,>( const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>, { fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement> ref: React.ForwardedRef<HTMLDivElement>
) => { ) => {
const theme = useTheme(); const theme = useTheme();
@@ -28,7 +27,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === ""; : selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) { if (isEmpty && placeholder) {
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>; return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
} }
if (isMultiple) { if (isMultiple) {
@@ -93,21 +92,11 @@ const SelectInputInner = <T,>(
/> />
); );
const helper = helperText ? ( if (fieldLabel) {
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
return ( return (
<Stack spacing={theme.spacing(2)}> <Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>} <FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select} {select}
{helper}
</Stack> </Stack>
); );
} }
+12 -21
View File
@@ -3,7 +3,6 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers"; import { LocalizationProvider } from "@mui/x-date-pickers";
@@ -11,15 +10,11 @@ import { LocalizationProvider } from "@mui/x-date-pickers";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> { interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
error?: boolean;
helperText?: string;
} }
export const TimePickerComponent = ({ export const TimePickerComponent = ({
fieldLabel, fieldLabel,
required, required,
error,
helperText,
...props ...props
}: TimePickerComponentProps) => { }: TimePickerComponentProps) => {
const theme = useTheme(); const theme = useTheme();
@@ -38,12 +33,12 @@ export const TimePickerComponent = ({
px: theme.spacing(5), px: theme.spacing(5),
}, },
"& fieldset": { "& fieldset": {
borderColor: error ? theme.palette.error.main : theme.palette.divider, borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
}, },
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{ {
borderColor: error ? theme.palette.error.main : theme.palette.divider, borderColor: theme.palette.divider,
}, },
}, },
}, },
@@ -53,18 +48,14 @@ export const TimePickerComponent = ({
</LocalizationProvider> </LocalizationProvider>
); );
return ( if (fieldLabel) {
<Stack spacing={theme.spacing(2)}> return (
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>} <Stack spacing={theme.spacing(2)}>
{picker} <FieldLabel required={required}>{fieldLabel}</FieldLabel>
{helperText && ( {picker}
<Typography </Stack>
variant="caption" );
color={error ? "error" : "text.secondary"} }
>
{helperText} return picker;
</Typography>
)}
</Stack>
);
}; };
@@ -1,52 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
export interface AutocompleteOption {
id: string;
name: string;
}
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
}
export const FormAutocompleteField = <
T extends FieldValues,
O extends AutocompleteOption,
>({
name,
options,
fieldLabel,
placeholder,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Autocomplete
options={options}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -1,38 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Checkbox } from "@/Components/inputs";
interface FormCheckboxFieldProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
}
export const FormCheckboxField = <T extends FieldValues>({
name,
label,
}: FormCheckboxFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label={label}
/>
)}
/>
);
};
@@ -1,35 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { ColorInput } from "@/Components/inputs";
interface FormColorFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
}
export const FormColorField = <T extends FieldValues>({
name,
fieldLabel,
}: FormColorFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={fieldLabel}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -1,81 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import dayjs, { type Dayjs } from "dayjs";
import { DatePicker, TimePicker } from "@/Components/inputs";
export interface FormPickerFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
format?: string;
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
}
interface DayjsPickerProps {
fieldLabel?: string;
value: Dayjs | null;
onChange: (value: Dayjs | null) => void;
error?: boolean;
helperText?: string;
}
interface FormDayjsPickerFieldProps<
T extends FieldValues,
> extends FormPickerFieldProps<T> {
picker: React.ComponentType<DayjsPickerProps>;
format: string;
}
const FormDayjsPickerField = <T extends FieldValues>({
name,
fieldLabel,
format,
onValueChange,
picker: Picker,
}: FormDayjsPickerFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Picker
fieldLabel={fieldLabel}
value={field.value ? dayjs(field.value, format) : null}
onChange={(value) => {
const next = value ? value.format(format) : "";
field.onChange(next);
onValueChange?.(next);
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
);
};
export const FormDatePickerField = <T extends FieldValues>({
format = "YYYY-MM-DD",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={DatePicker}
/>
);
export const FormTimePickerField = <T extends FieldValues>({
format = "HH:mm",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={TimePicker}
/>
);
@@ -1,187 +0,0 @@
import { useMemo } from "react";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import { useTranslation } from "react-i18next";
import { Autocomplete } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
export interface MultiSelectOption {
id: string | number;
name: string;
}
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
description?: string;
renderRow?: (option: O) => React.ReactNode;
renderOptionContent?: (option: O) => React.ReactNode;
sortable?: boolean;
}
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
name,
options,
fieldLabel,
placeholder,
description,
renderRow,
renderOptionContent,
sortable = false,
}: FormMultiSelectFieldProps<T, O>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
const { t } = useTranslation();
const optionsById = useMemo(
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
[options]
);
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
const selectedIds: O["id"][] = field.value ?? [];
const selected = selectedIds
.map((id) => optionsById.get(id))
.filter((o): o is O => o !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = selected.map((o) => o.id);
const [moved] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, moved);
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...reordered, ...unresolved]);
};
const rowContent = (option: O) =>
renderRow ? (
renderRow(option)
) : (
<Typography flexGrow={1}>{option.name}</Typography>
);
const removeButton = (option: O) => (
<IconButton
size="small"
onClick={() => {
field.onChange(selectedIds.filter((id) => id !== option.id));
}}
aria-label={t("common.buttons.removeItem", { item: option.name })}
>
<Trash2 size={16} />
</IconButton>
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={options}
value={selected}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(_: unknown, newValue: O[]) => {
// The Autocomplete only sees resolved options, so ids missing
// from options must be re-appended or they'd be silently
// dropped (e.g. a role excluded from the selectable list).
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
}}
fieldLabel={fieldLabel}
placeholder={selected.length > 0 ? "" : placeholder}
renderOptionContent={renderOptionContent}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
{description && (
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{description}
</Typography>
)}
{sortable
? selected.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={`${name}-list`}>
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selected.map((option, index) => (
<Draggable
key={option.id}
draggableId={String(option.id)}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
{rowContent(option)}
{removeButton(option)}
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)
: selected.map((option) => (
<Stack
key={option.id}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
width="100%"
>
{rowContent(option)}
{removeButton(option)}
</Stack>
))}
</Stack>
);
}}
/>
);
};
@@ -1,46 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText" | "type"
> {
name: FieldPath<T>;
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
}
export const FormNumberField = <T extends FieldValues>({
name,
emptyValue = 0,
...rest
}: FormNumberFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
value={
field.value === undefined || field.value === emptyValue ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? emptyValue : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
{...rest}
/>
)}
/>
);
};
@@ -1,54 +0,0 @@
import { RadioGroup, useTheme } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { LAYOUT } from "@/Utils/Theme/constants";
import { RadioWithDescription } from "@/Components/inputs/Radio";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
export interface FormRadioOption {
value: string;
label: string;
description: string;
}
interface FormRadioGroupProps<T extends FieldValues> {
name: FieldPath<T>;
options: FormRadioOption[];
fieldLabel?: string;
}
export const FormRadioGroup = <T extends FieldValues>({
name,
options,
fieldLabel,
}: FormRadioGroupProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
>
{options.map((option) => (
<RadioWithDescription
key={option.value}
value={option.value}
label={option.label}
description={option.description}
/>
))}
</RadioGroup>
</FormControl>
)}
/>
);
};
@@ -1,61 +0,0 @@
import {
Controller,
useFormContext,
type FieldValues,
type FieldPath,
} from "react-hook-form";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/inputs";
export interface FormSelectOption {
value: string | number;
label: string;
}
interface FormSelectProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof Select>,
"name" | "error" | "value" | "onChange"
> {
name: FieldPath<T>;
options: FormSelectOption[];
fallbackValue?: string | number; // display value while the field is undefined
onValueChange?: (value: string | number) => void; // side effects, see Notes
}
export const FormSelectField = <T extends FieldValues>({
name,
options,
fallbackValue,
onValueChange,
...rest
}: FormSelectProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? fallbackValue ?? ""}
onChange={(e) => {
field.onChange(e.target.value);
onValueChange?.(e.target.value as string | number);
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
);
};
@@ -1,34 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { SliderWithLabel } from "@/Components/inputs";
interface FormSliderProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof SliderWithLabel>,
"name" | "value" | "onChange"
> {
name: FieldPath<T>;
}
export const FormSliderField = <T extends FieldValues>({
name,
...rest
}: FormSliderProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
{...rest}
/>
)}
/>
);
};
@@ -1,59 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { SwitchComponent } from "@/Components/inputs";
import { SPACING } from "@/Utils/Theme/constants";
interface FormSwitchProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
labelPlacement?: "start" | "end"; // left | right
}
export const FormSwitchField = <T extends FieldValues>({
name,
label,
labelPlacement = "end",
}: FormSwitchProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
>
{labelPlacement === "start" ? (
<>
<Typography>{label}</Typography>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
</>
) : (
<>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>{label}</Typography>
</>
)}
</Stack>
)}
/>
);
};
@@ -1,40 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormTextFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText"
> {
name: FieldPath<T>;
helperText?: string;
}
export const FormTextField = <T extends FieldValues>({
name,
...rest
}: FormTextFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
{...rest}
value={field.value ?? ""}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
fullWidth
/>
)}
/>
);
};
+9 -7
View File
@@ -18,13 +18,15 @@ const RootLayout = () => {
flex={1} flex={1}
padding={6} padding={6}
overflow={"hidden"} overflow={"hidden"}
bgcolor={ sx={{
theme.palette.mode === "dark" backgroundColor:
? "rgba(255, 255, 255, 0.01)" theme.palette.mode === "dark"
: "rgba(124, 116, 116, 0.01)" ? "rgba(255, 255, 255, 0.01)"
} : "rgba(0, 0, 0, 0.01)",
alignItems="center" display: "flex",
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12} alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
> >
<Stack <Stack
maxWidth={1280} maxWidth={1280}
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode"; import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { formatMs } from "@/Utils/TimeUtils";
interface GeoChecksMapProps { interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[]; geoChecks: FlatGeoCheck[];
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
return status ? theme.palette.success.main : theme.palette.error.main; return status ? theme.palette.success.main : theme.palette.error.main;
}; };
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return ( return (
<> <>
{mapPopupStyles} {mapPopupStyles}
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
> >
Response Time: Response Time:
</Typography>{" "} </Typography>{" "}
{formatMs(selectedCheck.timings.total)} {formatResponseTime(selectedCheck.timings.total)}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
@@ -1,24 +1,22 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements"; import { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor"; import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils"; import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
interface MonitorStatBoxesProps { interface MonitorStatBoxesProps {
monitor?: Monitor; monitor?: Monitor;
monitorStats: MonitorStats | null; monitorStats: MonitorStats | null;
certificateExpiry?: string; certificateExpiry?: string;
domainExpiry?: string;
} }
export const MonitorStatBoxes = ({ export const MonitorStatBoxes = ({
monitor, monitor,
monitorStats, monitorStats,
certificateExpiry, certificateExpiry,
domainExpiry,
}: MonitorStatBoxesProps) => { }: MonitorStatBoxesProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0; const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure); const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const lastCheckTime = formatDuration(timeSinceLastCheck); const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive = const isActive =
monitor?.status === "up" || monitor?.status === "up" ||
monitor?.status === "paused" || monitor?.status === "paused" ||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
/> />
<StatBox <StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")} title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)} subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/> />
{monitor?.type === "http" && ( {monitor?.type === "http" && (
<> <StatBox
<StatBox title={t("pages.common.monitors.statBoxes.certificateExpiry")}
title={t("pages.common.monitors.statBoxes.certificateExpiry")} subtitle={certificateExpiry || "N/A"}
subtitle={certificateExpiry || "N/A"} />
/>
<StatBox
title={t("pages.common.monitors.statBoxes.domainExpiry")}
subtitle={domainExpiry || "N/A"}
/>
</>
)} )}
</Stack> </Stack>
); );
@@ -1,34 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -1,39 +1,53 @@
import Stack from "@mui/material/Stack";
import { BaseChart, BaseBox } from "@/Components/design-elements"; import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import { import {
AreaChart, AreaChart,
Area, Area,
XAxis, XAxis,
YAxis,
Tooltip, Tooltip,
CartesianGrid, CartesianGrid,
ResponsiveContainer, ResponsiveContainer,
Text,
} from "recharts"; } from "recharts";
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next"; import {
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
import { useMemo, useState } from "react"; type XTickProps = {
import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; x: number;
import { y: number;
PHASE_COLOR_KEYS, payload: { value: string };
PHASE_KEYS, range: string;
PHASE_LABEL_KEYS, };
} from "@/Components/monitors/charts/ChartUtils";
type DetailsCheck = GroupedCheck & export const XTick = ({ x, y, payload, range }: XTickProps) => {
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>; const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & { type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string; range: string;
@@ -49,65 +63,17 @@ const ResponseTimeToolTip = ({
theme, theme,
uiTimezone, uiTimezone,
}: ResponseTimeToolTipProps) => { }: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null; if (!label) return null;
if (!payload) return null; if (!payload) return null;
if (!active) return null; if (!active) return null;
const format = tooltipDateFormatLookup(range); const format = tooltipDateFormatLookup(range);
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0; console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return ( return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}> <BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography> <Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography> <Typography>Response time: {responseTime} ms</Typography>
{t("common.charts.histogram.responseTime", {
value: formatMs(responseTime),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: formatMs(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
</Typography>
</Stack>
))}
</BaseBox> </BaseBox>
); );
}; };
@@ -116,28 +82,11 @@ export const HistogramDetails = ({
checks, checks,
range, range,
}: { }: {
checks: DetailsCheck[]; checks: GroupedCheck[];
range: string; range: string;
}) => { }) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return ( return (
<BaseChart <BaseChart
icon={ icon={
@@ -146,15 +95,7 @@ export const HistogramDetails = ({
strokeWidth={1.5} strokeWidth={1.5}
/> />
} }
title={ title="Response times"
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
> >
<ResponsiveContainer <ResponsiveContainer
width="100%" width="100%"
@@ -187,34 +128,7 @@ export const HistogramDetails = ({
stopOpacity={0} stopOpacity={0}
/> />
</linearGradient> </linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs> </defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis <XAxis
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
@@ -227,99 +141,24 @@ export const HistogramDetails = ({
)} )}
/> />
{showTimingPhases ? ( <Tooltip
<Tooltip content={(props) => (
content={(props) => ( <ResponseTimeToolTip
<TimingPhasesToolTip {...props}
{...props} range={range}
range={range} theme={theme}
theme={theme} uiTimezone={uiTimezone}
uiTimezone={uiTimezone} />
/> )}
)} />
/> <Area
) : ( type="monotone"
<Tooltip dataKey="avgResponseTime"
content={(props) => ( stroke={theme.palette.primary.main}
<ResponseTimeToolTip fill="url(#colorUv)"
{...props} />
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart> </BaseChart>
); );
}; };
@@ -8,7 +8,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { Fragment, useId } from "react"; import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors/charts/XTick"; import { XTick } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor"; import type { HardwareCheckStats } from "@/Types/Monitor";
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements"; import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors/charts/XTick"; import { XTick } from "@/Components/monitors";
import { import {
XAxis, XAxis,
AreaChart, AreaChart,
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements"; import { BaseChart } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts"; import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { getResponseTimeColor } from "@/Utils/MonitorUtils";
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
</Typography> </Typography>
) : null} ) : null}
<Typography variant="caption">{titleText}</Typography> <Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{formatMs(avg)}</Typography> <Typography variant="body2">{Math.floor(avg)} ms</Typography>
</Stack> </Stack>
); );
}; };
@@ -7,7 +7,6 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { formatMs } from "@/Utils/TimeUtils";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => { export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -85,9 +84,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
<Typography <Typography
variant="h1" variant="h1"
textAlign={"center"} textAlign={"center"}
> >{`${avg?.toFixed()}ms`}</Typography>
{formatMs(avg)}
</Typography>
</Stack> </Stack>
</Stack> </Stack>
</BaseChart> </BaseChart>
@@ -1,30 +0,0 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
+9 -21
View File
@@ -10,11 +10,10 @@ import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react"; import { MoreVertical, LogOut } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements"; import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js"; import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js"; import type { RootState } from "@/Types/state.js";
import { setMode } from "@/Features/UI/uiSlice.js";
interface AuthFooterProps { interface AuthFooterProps {
collapsed: boolean; collapsed: boolean;
@@ -28,7 +27,6 @@ interface AuthFooterProps {
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => { export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const user = useSelector((state: RootState) => state.auth.user); const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Typography <Typography
fontWeight={500} fontWeight={500}
noWrap noWrap
color={theme.palette.text.primary} sx={{ color: theme.palette.text.primary }}
> >
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Typography> </Typography>
<Typography <Typography
noWrap noWrap
textTransform={"capitalize"} sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
color={theme.palette.text.secondary}
> >
{getRoleText()} {getRoleText()}
</Typography> </Typography>
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
pb={2} pb={2}
sx={{ pointerEvents: "none" }} sx={{ pointerEvents: "none" }}
> >
<Typography fontWeight={500}> <Typography
fontWeight={500}
fontSize={13}
>
{user?.firstName} {user?.lastName} {user?.firstName} {user?.lastName}
</Typography> </Typography>
<Typography <Typography
variant="body2" fontSize={12}
textTransform={"capitalize"} sx={{ textTransform: "capitalize" }}
> >
{getRoleText()} {getRoleText()}
</Typography> </Typography>
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
{item.name} {item.name}
</MenuItem> </MenuItem>
))} ))}
<MenuItem
onClick={() => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
handleMenuClose();
}}
sx={menuItemSx}
>
<Icon icon={mode === "light" ? Moon : Sun} />
{mode === "light"
? t("pages.settings.form.ui.option.theme.dark")
: t("pages.settings.form.ui.option.theme.light")}
</MenuItem>
<MenuItem <MenuItem
onClick={handleLogout} onClick={handleLogout}
sx={menuItemSx} sx={menuItemSx}
+1 -6
View File
@@ -18,8 +18,8 @@ import {
Lock, Lock,
Users, Users,
Tag, Tag,
Waypoints,
} from "lucide-react"; } from "lucide-react";
export const getMenu = (t: Function) => { export const getMenu = (t: Function) => {
return [ return [
{ {
@@ -47,11 +47,6 @@ export const getMenu = (t: Function) => {
path: "tags", path: "tags",
icon: <Icon icon={Tag} />, icon: <Icon icon={Tag} />,
}, },
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{ {
name: t("components.sidebar.menu.checks"), name: t("components.sidebar.menu.checks"),
path: "checks", path: "checks",
-1
View File
@@ -102,7 +102,6 @@ export const Sidebar = () => {
})} })}
</List> </List>
<StarPrompt /> <StarPrompt />
<Divider sx={{ borderColor: theme.palette.divider }} /> <Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter <AuthFooter
+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: [],
+80 -8
View File
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
} }
function buildDefaults(data: Notification | null): NotificationFormData { function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return { return {
type: data?.type ?? "email", type: "email",
notificationName: data?.notificationName || "", notificationName: data?.notificationName || "",
address: data?.address || "", address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
}; };
} }
-22
View File
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
-2
View File
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "", pagespeedApiKey: "",
systemEmailPassword: "", systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
}; };
return { schema: settingsSchema, defaults }; return { schema: settingsSchema, defaults };
+103 -39
View File
@@ -1,6 +1,11 @@
import { TextField, Button } from "@/Components/inputs"; import { Trash2 } from "lucide-react";
import { TextField, Button, Autocomplete } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { FormProvider, useForm } from "react-hook-form"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import FormHelperText from "@mui/material/FormHelperText";
import { useForm, Controller } from "react-hook-form";
import { ConfigBox, BasePage } from "@/Components/design-elements"; import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User"; import { UserRoles } from "@/Types/User";
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
interface RoleOption { interface RoleOption {
id: UserRole; id: UserRole;
@@ -40,11 +43,10 @@ const EditUserPage = () => {
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`); const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
const form = useForm<EditUserFormData>({ const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, reset } = form;
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -62,6 +64,13 @@ const EditUserPage = () => {
name: t(`common.auth.roles.${role}`), name: t(`common.auth.roles.${role}`),
})); }));
const watchedRoles = watch("role");
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
const handleRemoveRole = (roleToRemove: UserRole) => {
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser = const canDeleteUser =
isAdmin && isAdmin &&
userId !== currentUser?.id && userId !== currentUser?.id &&
@@ -81,31 +90,42 @@ const EditUserPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage loading={isLoading}>
<BasePage <form onSubmit={handleSubmit(onSubmit)}>
loading={isLoading}
component={"form"}
onSubmit={handleSubmit(onSubmit)}
>
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<ConfigBox <ConfigBox
title={t("pages.account.form.name.title")} title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")} subtitle={t("pages.account.form.name.description")}
rightContent={ rightContent={
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<FormTextField <Controller
name="firstName" name="firstName"
autoComplete="given-name" control={control}
fieldLabel={t("common.form.name.option.firstName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.firstName.placeholder")} <TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="given-name"
/>
)}
/> />
<FormTextField <Controller
name="lastName" name="lastName"
autoComplete="family-name" control={control}
fieldLabel={t("common.form.name.option.lastName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.lastName.placeholder")} <TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="family-name"
/>
)}
/> />
<TextField <TextField
fieldLabel={t("common.form.email.option.email.label")} fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")} placeholder={t("common.form.email.option.email.placeholder")}
@@ -119,11 +139,55 @@ const EditUserPage = () => {
title={t("pages.editUser.form.roles.title")} title={t("pages.editUser.form.roles.title")}
subtitle={t("pages.editUser.form.roles.description")} subtitle={t("pages.editUser.form.roles.description")}
rightContent={ rightContent={
<FormMultiSelectField <Stack spacing={theme.spacing(4)}>
name="role" <Controller
fieldLabel={t("common.form.role.option.role.label")} name="role"
options={roleOptions} control={control}
/> render={({ field, fieldState }) => (
<>
<Autocomplete
fieldLabel={t("common.form.role.option.role.label")}
multiple
options={roleOptions}
value={selectedRoles}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: RoleOption[]) => {
field.onChange(newValue.map((r) => r.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{selectedRoles.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedRoles.map((role, index) => (
<Stack
direction="row"
alignItems="center"
key={role.id}
width="100%"
>
<Typography flexGrow={1}>{role.name}</Typography>
<IconButton
size="small"
onClick={() => handleRemoveRole(role.id)}
aria-label="Remove role"
>
<Trash2 size={16} />
</IconButton>
{index < selectedRoles.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
} }
/> />
<Stack <Stack
@@ -153,19 +217,19 @@ const EditUserPage = () => {
</Button> </Button>
</Stack> </Stack>
</Stack> </Stack>
<DialogInput </form>
open={showDeleteDialog} <DialogInput
title={t("pages.editUser.dialog.removeUser.title")} open={showDeleteDialog}
content={t("pages.editUser.dialog.removeUser.content", { title={t("pages.editUser.dialog.removeUser.title")}
name: `${user?.firstName} ${user?.lastName}`, content={t("pages.editUser.dialog.removeUser.content", {
})} name: `${user?.firstName} ${user?.lastName}`,
onCancel={() => setShowDeleteDialog(false)} })}
onConfirm={handleDeleteUser} onCancel={() => setShowDeleteDialog(false)}
confirmText={t("common.buttons.removeUser")} onConfirm={handleDeleteUser}
loading={isDeleting} confirmText={t("common.buttons.removeUser")}
/> loading={isDeleting}
</BasePage> />
</FormProvider> </BasePage>
); );
}; };
+82 -61
View File
@@ -1,15 +1,13 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements"; import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button } from "@/Components/inputs";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { usePasswordForm } from "@/Hooks/usePasswordForm"; import { usePasswordForm } from "@/Hooks/usePasswordForm";
import { usePatch } from "@/Hooks/UseApi"; import { usePatch } from "@/Hooks/UseApi";
import type { PasswordFormData } from "@/Validation/password"; import type { PasswordFormData } from "@/Validation/password";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabPassword = () => { export const TabPassword = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,11 +15,10 @@ export const TabPassword = () => {
const { resolver, defaults } = usePasswordForm(); const { resolver, defaults } = usePasswordForm();
const { patch, loading } = usePatch<FormData, void>(); const { patch, loading } = usePatch<FormData, void>();
const form = useForm<PasswordFormData>({ const { control, handleSubmit, reset } = useForm<PasswordFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, reset } = form;
const onSubmit = async (data: PasswordFormData) => { const onSubmit = async (data: PasswordFormData) => {
const fd = new FormData(); const fd = new FormData();
@@ -35,61 +32,85 @@ export const TabPassword = () => {
}; };
return ( return (
<FormProvider {...form}> <Stack
<Stack gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.account.form.currentPassword.title")}
title={t("pages.account.form.currentPassword.title")} subtitle={t("pages.account.form.currentPassword.description")}
subtitle={t("pages.account.form.currentPassword.description")} rightContent={
rightContent={ <Controller
<FormTextField name="currentPassword"
name="currentPassword" control={control}
type="password" render={({ field, fieldState }) => (
autoComplete="current-password" <TextField
fieldLabel={t("pages.account.form.currentPassword.option.label")} {...field}
placeholder={t("pages.account.form.currentPassword.option.placeholder")} type="password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
autoComplete="current-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="newPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.account.form.newPassword.option.newPassword.label"
)}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/> />
} <Controller
/> name="confirm"
<ConfigBox control={control}
title={t("pages.account.form.newPassword.title")} render={({ field, fieldState }) => (
subtitle={t("pages.account.form.newPassword.description")} <TextField
rightContent={ {...field}
<Stack gap={theme.spacing(LAYOUT.MD)}> type="password"
<FormTextField fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
name="newPassword" placeholder={t(
type="password" "pages.account.form.newPassword.option.confirm.placeholder"
autoComplete="new-password" )}
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")} autoComplete="new-password"
placeholder={t( error={!!fieldState.error}
"pages.account.form.newPassword.option.newPassword.placeholder" helperText={fieldState.error?.message ?? ""}
)} />
/> )}
<FormTextField />
name="confirm" </Stack>
type="password" }
autoComplete="new-password" />
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")} <Button
placeholder={t( type="submit"
"pages.account.form.newPassword.option.confirm.placeholder" variant="contained"
)} color="primary"
/> loading={loading}
</Stack> sx={{ alignSelf: "flex-end", minWidth: 100 }}
} >
/> {t("common.buttons.save")}
<Button </Button>
type="submit" </Stack>
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
); );
}; };
+64 -52
View File
@@ -1,11 +1,11 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements"; import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button } from "@/Components/inputs";
import { ImageUpload } from "@/Components/inputs"; import { ImageUpload } from "@/Components/inputs";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { useProfileForm } from "@/Hooks/useProfileForm"; import { useProfileForm } from "@/Hooks/useProfileForm";
import { usePatch } from "@/Hooks/UseApi"; import { usePatch } from "@/Hooks/UseApi";
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
import type { ProfileFormData } from "@/Validation/profile"; import type { ProfileFormData } from "@/Validation/profile";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import type { User } from "@/Types/User"; import type { User } from "@/Types/User";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabProfile = () => { export const TabProfile = () => {
const theme = useTheme(); const theme = useTheme();
@@ -24,7 +22,7 @@ export const TabProfile = () => {
const { resolver, defaults } = useProfileForm(); const { resolver, defaults } = useProfileForm();
const { patch, loading: patchLoading } = usePatch<FormData, User>(); const { patch, loading: patchLoading } = usePatch<FormData, User>();
const form = useForm<ProfileFormData>({ const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
resolver, resolver,
defaultValues: { defaultValues: {
firstName: user?.firstName ?? defaults.firstName, firstName: user?.firstName ?? defaults.firstName,
@@ -33,7 +31,7 @@ export const TabProfile = () => {
deleteProfileImage: defaults.deleteProfileImage, deleteProfileImage: defaults.deleteProfileImage,
}, },
}); });
const { handleSubmit, setValue, watch } = form;
const currentImage = watch("profileImage"); const currentImage = watch("profileImage");
const deleteImage = watch("deleteProfileImage"); const deleteImage = watch("deleteProfileImage");
@@ -76,52 +74,66 @@ export const TabProfile = () => {
}; };
return ( return (
<FormProvider {...form}> <Stack
<Stack gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.account.form.name.title")}
title={t("pages.account.form.name.title")} subtitle={t("pages.account.form.name.description")}
subtitle={t("pages.account.form.name.description")} rightContent={
rightContent={ <Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(LAYOUT.MD)}> <Controller
<FormTextField name="firstName"
name="firstName" control={control}
autoComplete="given-name" render={({ field, fieldState }) => (
fieldLabel={t("common.form.name.option.firstName.label")} <TextField
placeholder={t("common.form.name.option.firstName.placeholder")} {...field}
/> fieldLabel={t("common.form.name.option.firstName.label")}
<FormTextField placeholder={t("common.form.name.option.firstName.placeholder")}
name="lastName" autoComplete="given-name"
autoComplete="family-name" error={!!fieldState.error}
fieldLabel={t("common.form.name.option.lastName.label")} helperText={fieldState.error?.message ?? ""}
placeholder={t("common.form.name.option.lastName.placeholder")} />
/> )}
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/> />
} <Controller
/> name="lastName"
<Button control={control}
type="submit" render={({ field, fieldState }) => (
variant="contained" <TextField
color="primary" {...field}
loading={patchLoading} fieldLabel={t("common.form.name.option.lastName.label")}
sx={{ alignSelf: "flex-end", minWidth: 100 }} placeholder={t("common.form.name.option.lastName.placeholder")}
> autoComplete="family-name"
{t("common.buttons.save")} error={!!fieldState.error}
</Button> helperText={fieldState.error?.message ?? ""}
</Stack> />
</FormProvider> )}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
); );
}; };
@@ -1,16 +1,15 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Dialog } from "@/Components/inputs"; import { Dialog, TextField, Select } from "@/Components/inputs";
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { User } from "@/Types/User"; import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
interface AddTeamMemberDialogProps { interface AddTeamMemberDialogProps {
open: boolean; open: boolean;
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
const { schema, defaults } = useAddTeamMemberForm(); const { schema, defaults } = useAddTeamMemberForm();
const { post, loading } = usePost<RegisterPayload, User>(); const { post, loading } = usePost<RegisterPayload, User>();
const form = useForm<AddTeamMemberFormData>({ const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
values: defaults, values: defaults,
}); });
const { handleSubmit, reset, setError } = form; const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => { const handleClose = () => {
reset(); reset();
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
const onSubmit = async (data: AddTeamMemberFormData) => { const onSubmit = async (data: AddTeamMemberFormData) => {
if (loading) return; if (loading) return;
const { confirm: _confirm, ...userData } = data; const { confirm, ...userData } = data;
const payload: RegisterPayload = userData; const payload: RegisterPayload = userData;
const result = await post("/auth/users", payload); const result = await post("/auth/users", payload);
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
}; };
return ( return (
<FormProvider {...form}> <Dialog
<Dialog open={open}
open={open} title={t("pages.account.team.addMember.title")}
title={t("pages.account.team.addMember.title")} content={t("pages.account.team.addMember.description")}
content={t("pages.account.team.addMember.description")} onCancel={handleClose}
onCancel={handleClose} onConfirm={handleSubmit(onSubmit)}
onConfirm={handleSubmit(onSubmit)} confirmText={t("common.buttons.addMember")}
confirmText={t("common.buttons.addMember")} loading={loading}
loading={loading} maxWidth="sm"
maxWidth="sm" fullWidth
fullWidth >
> <Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Stack
<Stack direction="row"
direction="row" gap={theme.spacing(LAYOUT.XS)}
gap={theme.spacing(LAYOUT.XS)} >
> <Controller
<FormTextField name="firstName"
name="firstName" control={control}
fieldLabel={t("common.form.name.option.firstName.label")} render={({ field, fieldState }) => (
placeholder={t("common.form.name.option.firstName.placeholder")} <TextField
/> {...field}
<FormTextField fieldLabel={t("common.form.name.option.firstName.label")}
name="lastName" placeholder={t("common.form.name.option.firstName.placeholder")}
fieldLabel={t("common.form.name.option.lastName.label")} error={!!fieldState.error}
placeholder={t("common.form.name.option.lastName.placeholder")} helperText={fieldState.error?.message ?? ""}
/> sx={{ flex: 1 }}
</Stack> />
<FormTextField )}
name="email" />
type="email" <Controller
fieldLabel={t("common.form.email.option.email.label")} name="lastName"
placeholder={t("common.form.email.option.email.placeholder")} control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/> />
<RoleSelectField />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
sx={{ flex: 1 }}
/>
<FormTextField
name="confirm"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
/>
</Stack>
</Stack> </Stack>
</Dialog> <Controller
</FormProvider> name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
); );
}; };
@@ -1,16 +1,16 @@
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { useTheme, Typography } from "@mui/material"; import MenuItem from "@mui/material/MenuItem";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Button } from "@/Components/inputs"; import { Dialog, TextField, Select, Button } from "@/Components/inputs";
import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm"; import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite"; import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig"; import { runtimeConfig } from "@/Utils/runtimeConfig";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
@@ -31,12 +31,11 @@ export const InviteTeamMemberDialog = ({
const { t } = useTranslation(); const { t } = useTranslation();
const { resolver, defaults } = useInviteForm(); const { resolver, defaults } = useInviteForm();
const form = useForm<InviteFormData>({ const { control, handleSubmit, reset } = useForm<InviteFormData>({
resolver, resolver,
defaultValues: defaults, defaultValues: defaults,
values: defaults, values: defaults,
}); });
const { handleSubmit, reset } = form;
const { post: generateToken, loading: generateLoading } = usePost< const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData, InviteFormData,
@@ -48,6 +47,12 @@ export const InviteTeamMemberDialog = ({
>(); >();
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => { const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data); const result = await generateToken("/invite", data);
if (result?.data?.token) { if (result?.data?.token) {
@@ -71,55 +76,88 @@ export const InviteTeamMemberDialog = ({
}; };
return ( return (
<FormProvider {...form}> <Dialog
<Dialog open={open}
open={open} title={t("pages.account.team.invite.title")}
title={t("pages.account.team.invite.title")} content={t("pages.account.team.invite.description")}
content={t("pages.account.team.invite.description")} onCancel={handleClose}
onCancel={handleClose} onConfirm={handleSubmit(handleSendInvite)}
onConfirm={handleSubmit(handleSendInvite)} confirmText={t("common.buttons.sendInvite")}
confirmText={t("common.buttons.sendInvite")} loading={sendLoading || generateLoading}
loading={sendLoading || generateLoading} maxWidth="sm"
maxWidth="sm" fullWidth
fullWidth additionalButtons={
additionalButtons={ <Button
<Button variant="contained"
variant="contained" color="primary"
color="primary" onClick={handleSubmit(handleGenerateToken)}
onClick={handleSubmit(handleGenerateToken)} loading={generateLoading || sendLoading}
loading={generateLoading || sendLoading} >
> {t("common.buttons.generateToken")}
{t("common.buttons.generateToken")} </Button>
</Button> }
} >
> <Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Controller
<FormTextField name="email"
name="email" control={control}
type="email" render={({ field, fieldState }) => (
fieldLabel={t("common.form.email.option.email.label")} <TextField
placeholder={t("common.form.email.option.email.placeholder")} {...field}
/> fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
<RoleSelectField /> type="email"
{inviteLink && ( fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<> <>
<Typography variant="body2"> <Select
{t("pages.account.team.invite.linkLabel")} {...field}
</Typography> value={Array.isArray(value) ? (value[0] ?? "") : ""}
<TextField onChange={(e) => onChange([e.target.value as UserRole])}
value={inviteLink} fieldLabel={t("common.form.role.option.role.label")}
fullWidth fullWidth
slotProps={{ error={!!fieldState.error}
input: { >
readOnly: true, {roleOptions.map((option) => (
}, <MenuItem
}} key={option.value}
/> value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</> </>
)} )}
</Stack> />
</Dialog> {inviteLink && (
</FormProvider> <>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
); );
}; };
@@ -1,45 +0,0 @@
import MenuItem from "@mui/material/MenuItem";
import FormHelperText from "@mui/material/FormHelperText";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Select } from "@/Components/inputs";
import { UserRoles } from "@/Types/User";
import type { UserRole } from "@/Types/User";
export const RoleSelectField = () => {
const { t } = useTranslation();
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
name: "role",
});
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
value: role,
label: t(`common.auth.roles.${role}`),
}));
return (
<>
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
);
};
+49 -38
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm"; import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login"; import type { LoginFormData } from "@/Validation/login";
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi"; import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const LoginPage = () => { const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,13 +31,11 @@ const LoginPage = () => {
const { schema, defaults } = useLoginForm(); const { schema, defaults } = useLoginForm();
const form = useForm<LoginFormData>({ const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit } = form;
if (isCheckingAdmin) return null; if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => { const onSubmit = async (data: LoginFormData) => {
@@ -53,39 +50,53 @@ const LoginPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.login.title")}
title={t("pages.auth.login.title")} subtitle={t("pages.auth.login.subtitle")}
subtitle={t("pages.auth.login.subtitle")} >
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
<FormTextField {t("pages.auth.login.submit")}
name="email" </Button>
fieldLabel={t("pages.auth.common.form.option.email.label")} <TextLink
placeholder={t("pages.auth.common.form.option.email.placeholder")} alignSelf={"center"}
/> text={t("pages.auth.login.links.forgotPassword.text")}
<FormTextField linkText={t("pages.auth.login.links.forgotPassword.linkText")}
name="password" href="/forgot-password"
type="password" />
fieldLabel={t("pages.auth.common.form.option.password.label")} </BaseAuthPage>
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+35 -31
View File
@@ -1,13 +1,12 @@
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery"; import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const ForgotPasswordPage = () => { const ForgotPasswordPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
const { schema, defaults } = useRecoveryForm(); const { schema, defaults } = useRecoveryForm();
const form = useForm<RecoveryFormData>({ const { control, handleSubmit } = useForm<RecoveryFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit } = form;
const onSubmit = async (data: RecoveryFormData) => { const onSubmit = async (data: RecoveryFormData) => {
if (loading) return; if (loading) return;
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.forgotPassword.title")}
title={t("pages.auth.forgotPassword.title")} subtitle={t("pages.auth.forgotPassword.subtitle")}
subtitle={t("pages.auth.forgotPassword.subtitle")} >
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
<FormTextField {t("pages.auth.forgotPassword.submit")}
name="email" </Button>
fieldLabel={t("pages.auth.common.form.option.email.label")} <TextLink
placeholder={t("pages.auth.common.form.option.email.placeholder")} alignSelf={"center"}
/> text={t("pages.auth.forgotPassword.links.login.text")}
<Button linkText={t("pages.auth.forgotPassword.links.login.linkText")}
variant="contained" href="/login"
type="submit" />
loading={loading} </BaseAuthPage>
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
+103 -70
View File
@@ -1,7 +1,7 @@
import { BaseAuthPage } from "@/Components/design-elements"; import { BaseAuthPage } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert"; import Alert from "@mui/material/Alert";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm"; import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register"; import type { RegisterFormData } from "@/Validation/register";
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User"; import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
interface RegisterPayload { interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">; user: Omit<RegisterFormData, "confirm">;
@@ -46,11 +45,10 @@ const RegisterPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const form = useForm<RegisterFormData>({ const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, setError, reset } = form;
useEffect(() => { useEffect(() => {
if (!token || hasVerified.current) return; if (!token || hasVerified.current) return;
@@ -66,14 +64,14 @@ const RegisterPage = () => {
navigate("/register", { replace: true }); navigate("/register", { replace: true });
} }
}); });
}, [token, defaults, navigate, reset, verifyToken]); }, [token]);
if (isCheckingAdmin) return null; if (isCheckingAdmin) return null;
const onSubmit = async (data: RegisterFormData) => { const onSubmit = async (data: RegisterFormData) => {
if (loading) return; if (loading) return;
const { confirm: _confirm, ...userData } = data; const { confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData }; const payload: RegisterPayload = { user: userData };
if (token) { if (token) {
payload.token = token; payload.token = token;
@@ -91,70 +89,105 @@ const RegisterPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.register.title")}
title={t("pages.auth.register.title")} subtitle={t("pages.auth.register.subtitle")}
subtitle={t("pages.auth.register.subtitle")} >
> {!token && (
{!token && ( <Alert
<Alert severity="info"
severity="info" icon={false}
icon={false} sx={(theme) => ({
sx={(theme) => ({ fontSize: 13,
fontSize: 13, lineHeight: 1.55,
lineHeight: 1.55, color: theme.palette.text.secondary,
color: theme.palette.text.secondary, backgroundColor: theme.palette.action.hover,
backgroundColor: theme.palette.action.hover, border: `1px solid ${theme.palette.divider}`,
border: `1px solid ${theme.palette.divider}`, borderRadius: 1,
borderRadius: 1, "& .MuiAlert-message": { padding: 0 },
"& .MuiAlert-message": { padding: 0 }, })}
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<FormTextField
name="email"
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
> >
{t("pages.auth.register.submit")} {t("pages.auth.register.setupNotice")}
</Button> </Alert>
</BaseAuthPage> )}
</FormProvider> <Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
); );
}; };
+78 -66
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm"; import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword"; import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const SetNewPasswordPage = () => { const SetNewPasswordPage = () => {
const theme = useTheme(); const theme = useTheme();
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
const { schema, defaults } = useSetNewPasswordForm(); const { schema, defaults } = useSetNewPasswordForm();
const form = useForm<SetNewPasswordFormData>({ const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, watch } = form;
const password = watch("password"); const password = watch("password");
const confirm = watch("confirm"); const confirm = watch("confirm");
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BaseAuthPage
<BaseAuthPage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} title={t("pages.auth.setNewPassword.title")}
title={t("pages.auth.setNewPassword.title")} subtitle={t("pages.auth.setNewPassword.subtitle")}
subtitle={t("pages.auth.setNewPassword.subtitle")} >
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
> >
<FormTextField {t("common.buttons.resetPassword")}
name="password" </Button>
type="password" <TextLink
fieldLabel={t("pages.auth.common.form.option.password.label")} alignSelf="center"
placeholder={t("pages.auth.common.form.option.password.placeholder")} text={t("pages.auth.forgotPassword.links.login.text")}
/> linkText={t("pages.auth.forgotPassword.links.login.linkText")}
<FormTextField href="/login"
name="confirm" />
type="password" </BaseAuthPage>
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
); );
}; };
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -1,5 +1,5 @@
import type { Incident, IncidentSummaryItem } from "@/Types/Incident"; import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
import { formatDuration } from "@/Utils/TimeUtils"; import prettyMilliseconds from "pretty-ms";
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">; type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
@@ -24,5 +24,8 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
return "-"; return "-";
} }
return formatDuration(durationMs); return prettyMilliseconds(durationMs, {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
});
}; };
@@ -25,11 +25,10 @@ export const InfraDetailsGauges = ({
<Stack <Stack
direction={isSmall ? "column" : "row"} direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)} spacing={theme.spacing(8)}
alignItems={"stretch"} alignItems={"center"}
> >
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.memory.title")} title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100} progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")} upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)} upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -38,7 +37,6 @@ export const InfraDetailsGauges = ({
/> />
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")} title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100} progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")} upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)} upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -51,7 +49,6 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx} key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`} // title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })} title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100} progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")} upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)} upperValue={prettyBytes(disk?.used_bytes || 0)}
@@ -11,8 +11,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const recentChecks = monitor?.recentChecks ?? []; const latestCheck = monitor?.recentChecks?.[0];
const latestCheck = recentChecks[recentChecks.length - 1];
// Get data from latest check // Get data from latest check
const physicalCores = getCores(latestCheck?.cpu?.physical_core); const physicalCores = getCores(latestCheck?.cpu?.physical_core);
const logicalCores = getCores(latestCheck?.cpu?.logical_core); const logicalCores = getCores(latestCheck?.cpu?.logical_core);
@@ -22,7 +21,13 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes = const diskTotalBytes =
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0; latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
const osPlatform = getOsAndPlatform(latestCheck?.host); const os = getOsAndPlatform(latestCheck?.host);
const platform = latestCheck?.host?.platform ?? undefined;
const osPlatform =
typeof os === "undefined" && typeof platform === "undefined"
? undefined
: `${os} ${platform}`;
return ( return (
<Stack <Stack
@@ -21,11 +21,10 @@ export const TabOverview = ({
} }
const checks = stats?.checks || []; const checks = stats?.checks || [];
const recentChecks = monitor.recentChecks ?? [];
return ( return (
<Stack gap={theme.spacing(8)}> <Stack gap={theme.spacing(8)}>
<StatusBoxes monitor={monitor} /> <StatusBoxes monitor={monitor} />
<InfraDetailsGauges snapshot={recentChecks[recentChecks.length - 1]} /> <InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
<InfraDetailsCharts <InfraDetailsCharts
checks={checks} checks={checks}
dateRange={dateRange} dateRange={dateRange}
@@ -248,8 +248,7 @@ export const InfraMonitorsTable = ({
id: "cpu", id: "cpu",
content: t("pages.infrastructure.table.headers.cpu"), content: t("pages.infrastructure.table.headers.cpu"),
render: (row) => { render: (row) => {
const recentChecks = row.recentChecks ?? []; const check = row.recentChecks?.[0];
const check = recentChecks[recentChecks.length - 1];
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100; const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
return <Gauge progress={cpuUsage} />; return <Gauge progress={cpuUsage} />;
}, },
@@ -258,8 +257,7 @@ export const InfraMonitorsTable = ({
id: "memory", id: "memory",
content: t("pages.infrastructure.table.headers.memory"), content: t("pages.infrastructure.table.headers.memory"),
render: (row) => { render: (row) => {
const recentChecks = row.recentChecks ?? []; const check = row.recentChecks?.[0];
const check = recentChecks[recentChecks.length - 1];
const memoryUsage = (check?.memory?.usage_percent || 0) * 100; const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
return <Gauge progress={memoryUsage} />; return <Gauge progress={memoryUsage} />;
}, },
@@ -268,8 +266,7 @@ export const InfraMonitorsTable = ({
id: "disk", id: "disk",
content: t("pages.infrastructure.table.headers.disk"), content: t("pages.infrastructure.table.headers.disk"),
render: (row) => { render: (row) => {
const recentChecks = row.recentChecks ?? []; const check = row.recentChecks?.[0];
const check = recentChecks[recentChecks.length - 1];
const totalDiskUsage = check?.disk?.reduce( const totalDiskUsage = check?.disk?.reduce(
(acc, disk) => acc + (disk?.usage_percent || 0), (acc, disk) => acc + (disk?.usage_percent || 0),
+9 -5
View File
@@ -2,10 +2,10 @@ import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements"; import { StatBox } from "@/Components/design-elements";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics"; import type { Diagnostics } from "@/Types/Diagnostics";
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
interface StatsProps { interface StatsProps {
diagnostics: Diagnostics | null; diagnostics: Diagnostics | null;
@@ -18,9 +18,13 @@ export const Stats = ({ diagnostics }: StatsProps) => {
const theme = useTheme(); const theme = useTheme();
const eventLoopDelay = diagnostics const eventLoopDelay = diagnostics
? formatMs(diagnostics.eventLoopDelayMs ?? 0) ? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})
: PLACEHOLDER;
const uptime = diagnostics
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
: PLACEHOLDER; : PLACEHOLDER;
const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER;
const usedHeap = diagnostics const usedHeap = diagnostics
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0) ? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
: PLACEHOLDER; : PLACEHOLDER;
@@ -33,10 +37,10 @@ export const Stats = ({ diagnostics }: StatsProps) => {
return ( return (
<Box <Box
display="grid"
gap={theme.spacing(8)}
sx={{ sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" }, gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: theme.spacing(8),
"& > *": { width: "100% !important" }, "& > *": { width: "100% !important" },
}} }}
> >
@@ -1,6 +1,7 @@
import { Table, Pagination } from "@/Components/design-elements"; import { Table, Pagination } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material"; import { Typography, useTheme } from "@mui/material";
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils"; import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
@@ -83,7 +84,7 @@ export const TableJobs = ({
id: "interval", id: "interval",
content: t("common.table.headers.interval"), content: t("common.table.headers.interval"),
render: (row) => ( render: (row) => (
<Typography sx={cellSx}>{formatDuration(row.monitorInterval ?? 0)}</Typography> <Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
), ),
}, },
{ {
@@ -4,6 +4,7 @@ import { Pagination } from "@/Components/design-elements/Table";
import { ActionsMenu } from "@/Components/actions-menu"; import { ActionsMenu } from "@/Components/actions-menu";
import { DialogInput } from "@/Components/inputs/Dialog"; import { DialogInput } from "@/Components/inputs/Dialog";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import type { ActionMenuItem } from "@/Components/actions-menu"; import type { ActionMenuItem } from "@/Components/actions-menu";
@@ -17,7 +18,6 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import { useState } from "react";
import { useDelete, usePatch } from "@/Hooks/UseApi"; import { useDelete, usePatch } from "@/Hooks/UseApi";
import { formatDuration } from "@/Utils/TimeUtils";
interface MaintenanceWindowTableProps { interface MaintenanceWindowTableProps {
maintenanceWindows: MaintenanceWindow[]; maintenanceWindows: MaintenanceWindow[];
@@ -51,7 +51,7 @@ const getTimeToNextWindow = (
// Window is in the future // Window is in the future
if (start.isAfter(now)) { if (start.isAfter(now)) {
return formatDuration(start.diff(now)); return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
} }
return "N/A"; return "N/A";
@@ -152,7 +152,9 @@ export const MaintenanceWindowTable = ({
id: "repeat", id: "repeat",
content: t("pages.maintenanceWindow.table.headers.repeat"), content: t("pages.maintenanceWindow.table.headers.repeat"),
render: (row) => render: (row) =>
row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat), row.repeat === 0
? t("common.labels.na")
: prettyMilliseconds(row.repeat, { verbose: true }),
}, },
{ {
id: "actions", id: "actions",
+258 -117
View File
@@ -1,8 +1,18 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import {
TextField,
Select,
DatePicker,
TimePicker,
Button,
Autocomplete,
} from "@/Components/inputs";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,17 +25,10 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr"; import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { useEffect } from "react"; import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { Trash2 } from "lucide-react";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormDatePickerField,
FormTimePickerField,
} from "@/Components/inputs/forms/FormDateTimePickerField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const CreateMaintenanceWindowPage = () => { const CreateMaintenanceWindowPage = () => {
const theme = useTheme(); const theme = useTheme();
@@ -54,7 +57,7 @@ const CreateMaintenanceWindowPage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { handleSubmit, trigger } = form; const { control, handleSubmit, trigger } = form;
useEffect(() => { useEffect(() => {
if (existingMaintenanceWindow) { if (existingMaintenanceWindow) {
form.reset(defaults); form.reset(defaults);
@@ -111,117 +114,255 @@ const CreateMaintenanceWindowPage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage
<BasePage component={"form"}
component={"form"} onSubmit={handleSubmit(onSubmit, onError)}
onSubmit={handleSubmit(onSubmit, onError)} >
> <ConfigBox
<ConfigBox title={t("pages.maintenanceWindow.form.general.title")}
title={t("pages.maintenanceWindow.form.general.title")} subtitle={t("pages.maintenanceWindow.form.general.description")}
subtitle={t("pages.maintenanceWindow.form.general.description")} rightContent={
rightContent={ <Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Controller
<FormTextField name="name"
name="name" control={control}
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")} defaultValue={defaults.name}
placeholder={t( render={({ field, fieldState }) => (
"pages.maintenanceWindow.form.general.option.name.placeholder" <TextField
)} {...field}
/> type="text"
<FormSelectField fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
name="repeat" placeholder={t(
fieldLabel={t("pages.maintenanceWindow.form.general.option.repeat.label")} "pages.maintenanceWindow.form.general.option.name.placeholder"
options={repeatOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormDatePickerField
name="startDate"
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
onValueChange={() => trigger("startDate")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTimePickerField
name="startTime"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
onValueChange={() => trigger("startDate")}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<FormNumberField
name="duration"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)} )}
sx={{ width: 120 }} fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/> />
<FormSelectField
name="durationUnit"
fieldLabel={" "}
options={durationUnitOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<FormMultiSelectField
name="monitors"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)} )}
placeholder={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
/> />
} <Controller
/> name="repeat"
<Stack control={control}
direction="row" defaultValue={defaults.repeat}
justifyContent="flex-end" render={({ field, fieldState }) => (
spacing={theme.spacing(SPACING.LG)} <Select
value={field.value}
fieldLabel={t(
"pages.maintenanceWindow.form.general.option.repeat.label"
)}
error={!!fieldState.error}
onChange={field.onChange}
>
{repeatOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startDate"
control={control}
defaultValue={defaults.startDate}
render={({ field, fieldState }) => (
<DatePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
onChange={(date) => {
field.onChange(date ? date.format("YYYY-MM-DD") : "");
trigger("startDate");
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startTime"
control={control}
defaultValue={defaults.startTime}
render={({ field }) => (
<TimePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
value={field.value ? dayjs(field.value, "HH:mm") : null}
onChange={(time) => {
field.onChange(time ? time.format("HH:mm") : "");
trigger("startDate");
}}
/>
)}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<Controller
name="duration"
control={control}
defaultValue={defaults.duration}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)}
value={field.value === 0 ? "" : field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ width: 120 }}
/>
)}
/>
<Controller
name="durationUnit"
control={control}
defaultValue={defaults.durationUnit}
render={({ field }) => (
<Select
fieldLabel=" "
value={field.value}
onChange={field.onChange}
sx={{ minWidth: 120 }}
>
{durationUnitOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
defaultValue={defaults.monitors}
render={({ field, fieldState }) => {
const monitorsList = monitors ?? [];
const selectedMonitors = field.value
.map((id: string) => monitorsList.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitorsList}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<Stack>
{selectedMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter((id: string) => id !== monitor.id)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
> >
<Button {t("common.buttons.save")}
loading={isLoading} </Button>
type="submit" </Stack>
variant="contained" </BasePage>
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
); );
}; };
+381 -192
View File
@@ -1,12 +1,14 @@
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Select, Button } from "@/Components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FormProvider, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useNotificationForm } from "@/Hooks/useNotificationForm"; import { useNotificationForm } from "@/Hooks/useNotificationForm";
@@ -14,8 +16,6 @@ import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification"; import type { Notification } from "@/Types/Notification";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { NotificationChannels } from "@/Types/Notification"; import { NotificationChannels } from "@/Types/Notification";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
const NotificationsCreatePage = () => { const NotificationsCreatePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,7 +39,7 @@ const NotificationsCreatePage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
useEffect(() => { useEffect(() => {
reset(defaults); reset(defaults);
@@ -93,195 +93,384 @@ const NotificationsCreatePage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage
<BasePage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
<ConfigBox
title={t("pages.notifications.form.notificationName.title")}
subtitle={t("pages.notifications.form.notificationName.description")}
rightContent={
<Controller
name="notificationName"
control={control}
defaultValue={defaults.notificationName}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
defaultValue={defaults.type}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t("pages.notifications.form.type.optionType")}
error={!!fieldState.error}
onChange={field.onChange}
>
{NotificationChannels.map((type: string) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform="capitalize">{type}</Typography>
</MenuItem>
))}
</Select>
)}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="topic"
control={control}
defaultValue={"topic" in defaults ? defaults.topic : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="homeserverUrl"
control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="roomId"
control={control}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t(
"pages.notifications.form.accessToken.optionAccessToken"
)}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
> >
<ConfigBox <Button
title={t("pages.notifications.form.notificationName.title")} variant="contained"
subtitle={t("pages.notifications.form.notificationName.description")} color="primary"
rightContent={ onClick={handleTest}
<FormTextField loading={isTesting}
name="notificationName"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<FormSelectField
name="type"
fieldLabel={t("pages.notifications.form.type.optionType")}
options={NotificationChannels.map((channel) => ({
value: channel,
label: t(`pages.notifications.form.type.value.${channel}`),
}))}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<FormTextField
name="address"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
/>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
/>
<FormTextField
name="topic"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t("pages.notifications.form.telegram.placeholderBotToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t("pages.notifications.form.pushover.placeholderAppToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accountSid"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t("pages.notifications.form.twilio.placeholderAccountSid")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t("pages.notifications.form.twilio.placeholderAuthToken")}
/>
<FormTextField
name="twilioPhoneNumber"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderFromNumber")}
/>
<FormTextField
name="phone"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="homeserverUrl"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
/>
<FormTextField
name="roomId"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.accessToken.optionAccessToken")}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
> >
<Button {t("common.buttons.test")}
variant="contained" </Button>
color="primary" <Button
onClick={handleTest} loading={isSubmitting || isPatching}
loading={isTesting} type="submit"
> variant="contained"
{t("common.buttons.test")} color="primary"
</Button> >
<Button {t("common.buttons.save")}
loading={isSubmitting || isPatching} </Button>
type="submit" </Stack>
variant="contained" </BasePage>
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
); );
}; };
+2 -4
View File
@@ -45,8 +45,6 @@ const PageSpeedDetails = () => {
const monitor = monitorData?.monitorData?.monitor; const monitor = monitorData?.monitorData?.monitor;
const groupedChecks = monitorData?.monitorData?.groupedChecks || []; const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
const monitorStats = monitorData?.monitorStats || null; const monitorStats = monitorData?.monitorStats || null;
const recentChecks = monitor?.recentChecks ?? [];
const latestCheck = recentChecks[recentChecks.length - 1];
return ( return (
<BasePage <BasePage
@@ -77,8 +75,8 @@ const PageSpeedDetails = () => {
direction={{ xs: "column", md: "row" }} direction={{ xs: "column", md: "row" }}
gap={theme.spacing(10)} gap={theme.spacing(10)}
> >
<PiePageSpeed latestCheck={latestCheck} /> <PiePageSpeed latestCheck={monitor?.recentChecks?.[0]} />
<PiePageSpeedLegend latestCheck={latestCheck} /> <PiePageSpeedLegend latestCheck={monitor?.recentChecks?.[0]} />
</Stack> </Stack>
</BasePage> </BasePage>
); );
@@ -1,103 +0,0 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { ProxyResponse } from "@/Types/Proxy";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface ProxiesTableProps {
proxies: ProxyResponse[];
setSelectedProxy: Function;
}
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(proxies);
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/proxies/configure/${proxy.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedProxy(proxy);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<ProxyResponse>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "protocol",
content: t("pages.proxies.table.headers.protocol"),
render: (row) => {
return <Typography>{row?.protocol}</Typography>;
},
},
{
id: "host",
content: t("pages.proxies.table.headers.host"),
render: (row) => {
return <Typography>{row?.host}</Typography>;
},
},
{
id: "port",
content: t("pages.proxies.table.headers.port"),
render: (row) => {
return <Typography>{row?.port}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/proxies/configure/${row.id}`);
}}
/>
{proxies.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
-213
View File
@@ -1,213 +0,0 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Button, FieldLabel } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import type { ProxyFormData } from "@/Validation/proxy";
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
import { useTranslation } from "react-i18next";
import { useProxiesForm } from "@/Hooks/useProxiesForm";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { useMemo } from "react";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const ProxiesCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { proxyId } = useParams<{ proxyId: string }>();
const isEditMode = Boolean(proxyId);
const { data: existingProxy } = useGet<ProxyResponse>(
isEditMode ? `/proxies/${proxyId}` : null
);
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
const { schema, defaults } = useProxiesForm({ data: existingProxy });
const form = useForm<ProxyFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { reset, handleSubmit } = form;
const isPasswordSet = existingProxy?.hasPassword ?? false;
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
const handleResetPassword = () => {
form.setValue("password", "");
setPasswordHasBeenReset(true);
};
const isUsernameSet = Boolean(existingProxy?.username);
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
const handleResetUsername = () => {
form.setValue("username", "");
setUsernameHasBeenReset(true);
};
const protocolOptions = useMemo(
() =>
ProxyProtocols.map((protocol) => ({
value: protocol,
label: protocol,
})),
[]
);
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
const onSubmit = async (data: ProxyFormData) => {
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
{
...data,
};
// Don't send password/username if one is set and the user hasn't clicked reset
if (isPasswordSet && !passwordHasBeenReset) {
delete payload.password;
} else if (passwordHasBeenReset && !payload.password) {
payload.clearPassword = true;
}
if (isUsernameSet && !usernameHasBeenReset) {
delete payload.username;
} else if (usernameHasBeenReset && !payload.username) {
payload.clearUsername = true;
}
const result = isEditMode
? await patch(`/proxies/${proxyId}`, payload)
: await post("/proxies", payload);
if (result?.success) {
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
navigate("/proxies");
}
};
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.proxies.form.name.title")}
subtitle={t("pages.proxies.form.name.description")}
rightContent={
<FormTextField
name="name"
fieldLabel={t("pages.proxies.form.name.optionName")}
placeholder={t("pages.proxies.form.name.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.proxies.form.server.title")}
subtitle={t("pages.proxies.form.server.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSelectField
name="protocol"
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
options={protocolOptions}
/>
<FormTextField
name="host"
fieldLabel={t("pages.proxies.form.server.option.host.label")}
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
/>
<FormNumberField
name="port"
fieldLabel={t("pages.proxies.form.server.option.port.label")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.proxies.form.auth.title")}
subtitle={t("pages.proxies.form.auth.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
{(isUsernameSet === false || usernameHasBeenReset === true) && (
<FormTextField
name="username"
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
/>
)}
{isUsernameSet === true && usernameHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.username.labelSet")}
</FieldLabel>
<Button
onClick={handleResetUsername}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
{(isPasswordSet === false || passwordHasBeenReset === true) && (
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
/>
)}
{isPasswordSet === true && passwordHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.password.labelSet")}
</FieldLabel>
<Button
onClick={handleResetPassword}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
</Stack>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
export default ProxiesCreatePage;
-72
View File
@@ -1,72 +0,0 @@
import { BasePageWithStates } from "@/Components/design-elements";
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { ProxyResponse } from "@/Types/Proxy";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const ProxiesPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
const isDialogOpen = Boolean(selectedProxy);
const {
data: proxies,
isLoading,
isValidating,
error,
refetch,
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedProxy) return;
await deleteFn(`/proxies/${selectedProxy.id}`);
setSelectedProxy(null);
refetch();
};
const handleCancel = () => {
setSelectedProxy(null);
};
return (
<BasePageWithStates
headerKey="proxies"
page={t("pages.proxies.fallback.title")}
description={t("pages.proxies.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={proxies?.length ?? 0}
actionButtonText={t("pages.proxies.fallback.actionButton")}
actionLink="/proxies/create"
>
<HeaderCreate
path="/proxies/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<ProxiesTable
proxies={proxies ?? []}
setSelectedProxy={setSelectedProxy}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default ProxiesPage;
+2 -1
View File
@@ -13,12 +13,13 @@ const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = []; const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) { for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1; const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 800) + 20; const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({ checks.push({
id: `dummy-${i}`, id: `dummy-${i}`,
status: isUp, status: isUp,
statusCode: isUp ? 200 : 500, statusCode: isUp ? 200 : 500,
responseTime: responseTime, responseTime: responseTime,
originalResponseTime: responseTime,
message: "", message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(), createdAt: new Date(Date.now() - i * 60000).toISOString(),
}); });
File diff suppressed because it is too large. Load diff
+493 -289
View File
@@ -1,81 +1,56 @@
import { import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
BasePage,
ColoredLabel,
ConfigBox,
StepProgress,
} from "@/Components/design-elements";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { ImageUpload, Button, Dialog } from "@/Components/inputs"; import IconButton from "@mui/material/IconButton";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import {
ImageUpload,
SwitchComponent,
Button,
TextField,
Autocomplete,
Checkbox,
Dialog,
ColorInput,
} from "@/Components/inputs";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm, FormProvider, useController } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage"; import type { StatusPageFormData } from "@/Validation/statusPage";
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage"; import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import { SelectableMonitorTypes } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { timezoneOptions } from "@/Utils/timezoneOptions"; import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr"; import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker"; import { ThemePicker } from "./Components/ThemePicker";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField";
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
const ThemePickerField = () => {
const { field: themeField } = useController<StatusPageFormData, "theme">({
name: "theme",
});
const { field: modeField } = useController<StatusPageFormData, "themeMode">({
name: "themeMode",
});
return (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
);
};
const LogoUploadField = () => {
const { field } = useController<StatusPageFormData, "logo">({ name: "logo" });
return (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
field.onChange(
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
);
}}
/>
);
};
// Derived from SelectableMonitorTypes so a newly supported monitor type is
// offered here automatically; the previous hardcoded list silently omitted DNS.
// "hardware" is appended because infrastructure monitors are created on their
// own page and so are absent from the selectable set.
const monitorsUrl = (() => { const monitorsUrl = (() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
[...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type)); ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
return `/monitors/team?${params.toString()}`; return `/monitors/team?${params.toString()}`;
})(); })();
interface TimezoneOption {
_id: string;
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) => const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
@@ -92,9 +67,7 @@ const CreateStatusPage = () => {
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url)); useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl); const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]); const monitors = monitorsResponse ?? [];
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
const { post, loading: isSubmittingPost } = usePost(); const { post, loading: isSubmittingPost } = usePost();
const { put, loading: isSubmittingPut } = usePut(); const { put, loading: isSubmittingPut } = usePut();
@@ -114,7 +87,7 @@ const CreateStatusPage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { reset, handleSubmit, trigger } = form; const { control, reset, handleSubmit, trigger } = form;
// Reset form when defaults change (from fetched data) // Reset form when defaults change (from fetched data)
useEffect(() => { useEffect(() => {
@@ -135,7 +108,7 @@ const CreateStatusPage = () => {
}; };
const handleBack = () => setCurrentStep((step) => step - 1); const handleBack = () => setCurrentStep((step) => step - 1);
const watchedMonitorIds: string[] = form.watch("monitors"); const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => { const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? []) const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id)) .map((id) => monitors.find((m) => m.id === id))
@@ -151,7 +124,7 @@ const CreateStatusPage = () => {
useEffect(() => { useEffect(() => {
form.setValue("type", computedTypes); form.setValue("type", computedTypes);
}, [computedTypes, form]); }, [computedTypes]);
const onError = (errors: any) => { const onError = (errors: any) => {
logger.debug("Status page validation errors", errors); logger.debug("Status page validation errors", errors);
@@ -205,8 +178,18 @@ const CreateStatusPage = () => {
if (data.logo === null) { if (data.logo === null) {
// Signal to remove the logo // Signal to remove the logo
fd.append("removeLogo", "true"); fd.append("removeLogo", "true");
} else if (data.logo?.file) { } else if (data.logo?.data && data.logo.data !== "") {
fd.append("logo", data.logo.file); if (data.logo.data.startsWith("blob:")) {
try {
const imageResult = await axios.get(data.logo.data, {
responseType: "blob",
});
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
}
}
} }
let result; let result;
@@ -233,249 +216,470 @@ const CreateStatusPage = () => {
return <BasePage>Loading...</BasePage>; return <BasePage>Loading...</BasePage>;
} }
return ( return (
<FormProvider {...form}> <BasePage
<BasePage component="form"
component="form" onSubmit={handleSubmit(onSubmit, onError)}
onSubmit={handleSubmit(onSubmit, onError)} >
> {!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />} {isCreate && (
{isCreate && ( <StepProgress
<StepProgress steps={totalSteps}
steps={totalSteps} current={currentStep}
current={currentStep} />
/> )}
)} {showStep(0) && (
{showStep(0) && ( <ConfigBox
<ConfigBox title={t("pages.statusPages.form.access.title")}
title={t("pages.statusPages.form.access.title")} subtitle={t("pages.statusPages.form.access.description")}
subtitle={t("pages.statusPages.form.access.description")} rightContent={
rightContent={ <Stack
<FormSwitchField direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
>
<Controller
name="isPublished" name="isPublished"
label={t("pages.statusPages.form.access.option.published.name")} control={control}
render={({ field }) => (
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/> />
} <Typography>
/> {t("pages.statusPages.form.access.option.published.name")}
)} </Typography>
{showStep(0) && ( </Stack>
<ConfigBox }
title={t("pages.statusPages.form.basicInfo.title")} />
subtitle={t("pages.statusPages.form.basicInfo.description")} )}
rightContent={ {showStep(0) && (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <ConfigBox
<FormTextField title={t("pages.statusPages.form.basicInfo.title")}
name="companyName" subtitle={t("pages.statusPages.form.basicInfo.description")}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")} rightContent={
placeholder={t( <Stack spacing={theme.spacing(LAYOUT.MD)}>
"pages.statusPages.form.basicInfo.option.name.placeholder" <Controller
)} name="companyName"
/> control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
render={({ field, fieldState }) => {
const selectedMonitors = field.value
.map((id: string) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
<FormTextField const handleDragEnd = (result: DropResult) => {
name="url" if (!result.destination) return;
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")} const reordered = Array.from(field.value);
placeholder={t( const [removed] = reordered.splice(result.source.index, 1);
"pages.statusPages.form.basicInfo.option.url.placeholder" reordered.splice(result.destination.index, 0, removed);
)} field.onChange(reordered);
/> };
<FormTextField
name="customDomain" return (
fieldLabel={t( <Stack spacing={theme.spacing(LAYOUT.MD)}>
"pages.statusPages.form.basicInfo.option.customDomain.label" <Autocomplete
)} multiple
placeholder={t( options={monitors}
"pages.statusPages.form.basicInfo.option.customDomain.placeholder" getOptionLabel={(option: Monitor) => option.name}
)} value={selectedMonitors}
helperText={t( onChange={(_, newValue) => {
"pages.statusPages.form.basicInfo.option.customDomain.helper" field.onChange(newValue.map((m: Monitor) => m.id));
)} }}
/> fieldLabel={t(
</Stack> "pages.statusPages.form.monitors.option.monitors.label"
} )}
/> renderInput={(params) => (
)} <TextField
{showStep(0) && ( {...params}
<ConfigBox placeholder={
title={t("pages.statusPages.form.monitors.title")} selectedMonitors.length === 0
subtitle={t("pages.statusPages.form.monitors.description")} ? t(
rightContent={ "pages.statusPages.form.monitors.option.monitors.placeholder"
<FormMultiSelectField )
sortable : ""
name="monitors" }
fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")} error={!!fieldState.error}
placeholder={t( helperText={fieldState.error?.message}
"pages.statusPages.form.monitors.option.monitors.placeholder"
)}
options={monitors}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
{monitor.tags.map((tagId) => {
const tag = tags.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/> />
) : null; )}
})} />
{selectedMonitors.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="monitors-list">
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack> </Stack>
)} );
renderRow={(monitor) => ( }}
<Typography flexGrow={1}> />
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} }
</Typography> />
)} )}
/> {showStep(0) && (
} <ConfigBox
/> title={t("pages.statusPages.form.timezone.title")}
)} subtitle={t("pages.statusPages.form.timezone.description")}
{showStep(0) && ( rightContent={
<ConfigBox <Controller
title={t("pages.statusPages.form.timezone.title")} name="timezone"
subtitle={t("pages.statusPages.form.timezone.description")} control={control}
rightContent={ render={({ field }) => (
<FormAutocompleteField <Autocomplete
name="timezone" options={timezones}
options={timezoneOptions} getOptionLabel={(option: TimezoneOption) => option.name}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} value={
placeholder={t( timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
"pages.statusPages.form.timezone.option.timezone.placeholder" }
)} onChange={(_, newValue: TimezoneOption | null) => {
/> field.onChange(newValue?._id ?? "");
} }}
/> fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
)} renderInput={(params) => (
{showStep(1) && ( <TextField
<ConfigBox {...params}
title={t("pages.statusPages.form.appearance.title")} placeholder={t(
subtitle={t("pages.statusPages.form.appearance.description")} "pages.statusPages.form.timezone.option.timezone.placeholder"
rightContent={ )}
<Stack spacing={theme.spacing(LAYOUT.MD)}> />
<Stack alignItems={"center"}>
<LogoUploadField />
</Stack>
<FormColorField
name="color"
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={<ThemePickerField />}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<FormTextField
name="customCSS"
multiline
rows={8}
fieldLabel={t("pages.statusPages.form.customCSS.option.customCSS.label")}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormCheckboxField
name="showCharts"
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
<FormCheckboxField
name="showInfrastructure"
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)} )}
/> />
{/* )}
<FormCheckboxField />
name="showUptimePercentage" }
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="showCharts"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t( label={t(
"pages.statusPages.form.features.option.showUptimePercentage.label" "pages.statusPages.form.features.option.showUptimePercentage.label"
)} )}
/> />
)}
<FormCheckboxField />
name="showAdminLoginLink" <Controller
name="showAdminLoginLink"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t( label={t(
"pages.statusPages.form.features.option.showAdminLoginLink.label" "pages.statusPages.form.features.option.showAdminLoginLink.label"
)} )}
/> */} />
</Stack> )}
} /> */}
/> </Stack>
)} }
<Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{t("common.buttons.back")}
</Button>
)}
{isCreate && currentStep < totalSteps - 1 ? (
<Button
key="wizard-next"
type="button"
variant="contained"
color="primary"
onClick={handleNext}
>
{t("common.buttons.next")}
</Button>
) : (
<Button
key="wizard-save"
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
)}
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/> />
</BasePage> )}
</FormProvider> <Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{t("common.buttons.back")}
</Button>
)}
{isCreate && currentStep < totalSteps - 1 ? (
<Button
key="wizard-next"
type="button"
variant="contained"
color="primary"
onClick={handleNext}
>
{t("common.buttons.next")}
</Button>
) : (
<Button
key="wizard-save"
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
)}
</Stack>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
</BasePage>
); );
}; };
+3 -27
View File
@@ -1,6 +1,6 @@
import { BasePage, BaseFallback } from "@/Components/design-elements"; import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { Link, useSearchParams } from "react-router-dom"; import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
@@ -9,11 +9,7 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi"; import { useGet } from "@/Hooks/UseApi";
import { import { resolveStatusPageTheme } from "@/Types/StatusPage";
isStatusPageRange,
resolveStatusPageTheme,
type StatusPageRange,
} from "@/Types/StatusPage";
import { import {
PUBLIC_STATUS_PAGE_PREFIX, PUBLIC_STATUS_PAGE_PREFIX,
type StatusPageResponse, type StatusPageResponse,
@@ -84,37 +80,21 @@ const StatusPageView = () => {
const { url } = useParams(); const { url } = useParams();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const onRangeChange = (next: StatusPageRange) => {
const updated = new URLSearchParams(searchParams);
if (next === "latest") {
updated.delete("range");
} else {
updated.set("range", next);
}
setSearchParams(updated, { replace: true });
};
const onCustomDomainHost = isCustomDomainHost(); const onCustomDomainHost = isCustomDomainHost();
const isPublic = const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const rawRange = searchParams.get("range");
const range = isStatusPageRange(rawRange) ? rawRange : "latest";
const apiUrl = buildStatusPageApiPath({ const apiUrl = buildStatusPageApiPath({
url, url,
useCustomDomain: onCustomDomainHost, useCustomDomain: onCustomDomainHost,
range,
}); });
const { data, isLoading, error } = useGet<StatusPageResponse>( const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl, apiUrl,
{}, {},
{ {
keepPreviousData: true, refreshInterval: 10000,
refreshInterval: range === "latest" ? 10000 : 60000,
} }
); );
@@ -170,10 +150,6 @@ const StatusPageView = () => {
statusPage={statusPage} statusPage={statusPage}
monitors={monitors} monitors={monitors}
config={themeConfig} config={themeConfig}
range={range}
onRangeChange={onRangeChange}
bucketTimezone={data.bucketTimezone ?? "Etc/UTC"}
checkTTLDays={data.checkTTLDays}
/> />
</StatusPageThemeProvider> </StatusPageThemeProvider>
); );
@@ -1,13 +1,11 @@
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens"; import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import type { BarKind, HeatCellKind } from "../shared/ChartCells"; export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
export type BoldHeatCell = HeatCellKind;
export type BoldBarKind = BarKind;
export type BoldGaugeFill = "ok" | "warm" | "hot"; export type BoldGaugeFill = "ok" | "warm" | "hot";
export interface BoldStyles { export interface BoldStyles {
@@ -34,9 +32,9 @@ export interface BoldStyles {
monitorUrl: SxProps<Theme>; monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>; badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>; heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>; heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>; histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>; bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>; chartStats: SxProps<Theme>;
infra: SxProps<Theme>; infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>; infraEmpty: SxProps<Theme>;
@@ -57,7 +55,6 @@ export const boldStyles = (
fast: tokens.up, fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`, med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
slow: tokens.warn, slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
@@ -65,14 +62,12 @@ export const boldStyles = (
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`, slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`,
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`, down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
empty: "none", empty: "none",
}; };
const barBg: Record<BoldBarKind, string> = { const barBg: Record<BoldBarKind, string> = {
up: tokens.up, up: tokens.up,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
@@ -327,12 +322,9 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" }, gap: { xs: "1px", md: "3px" },
height: 48, height: 48,
}, },
heatmapCell: (kind, severity = 1) => ({ heatmapCell: (kind) => ({
borderRadius: "3px", borderRadius: "3px",
background: background: heatCellBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
boxShadow: heatCellShadow[kind], boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1, opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s", transition: "transform 0.15s",
@@ -347,11 +339,8 @@ export const boldStyles = (
alignItems: "flex-end", alignItems: "flex-end",
height: 48, height: 48,
}, },
bar: (kind, heightPct, severity = 1) => ({ bar: (kind, heightPct) => ({
background: background: barBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
borderRadius: "3px", borderRadius: "3px",
minHeight: 4, minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1, opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens"; import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { import {
EDITORIAL_SECONDARY_SANS_STACK, EDITORIAL_SECONDARY_SANS_STACK,
@@ -8,10 +8,8 @@ import {
} from "../shared/fontStacks"; } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import type { BarKind, HeatCellKind } from "../shared/ChartCells"; export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
export type EditorialHeatCell = HeatCellKind;
export type EditorialBarKind = BarKind;
export type EditorialGaugeFill = "ok" | "warm" | "hot"; export type EditorialGaugeFill = "ok" | "warm" | "hot";
export interface EditorialStyles { export interface EditorialStyles {
@@ -38,9 +36,9 @@ export interface EditorialStyles {
monitorUrl: SxProps<Theme>; monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>; badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>; heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>; heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>; histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>; bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>; chartStats: SxProps<Theme>;
infra: SxProps<Theme>; infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>; infraEmpty: SxProps<Theme>;
@@ -61,14 +59,12 @@ export const editorialStyles = (
fast: tokens.up, fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
slow: tokens.warn, slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
const barBg: Record<EditorialBarKind, string> = { const barBg: Record<EditorialBarKind, string> = {
up: tokens.up, up: tokens.up,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
@@ -272,11 +268,8 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" }, gap: { xs: "1px", md: "2px" },
height: 40, height: 40,
}, },
heatmapCell: (kind, severity = 1) => ({ heatmapCell: (kind) => ({
background: background: heatCellBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1, opacity: kind === "empty" ? 0.5 : 1,
}), }),
@@ -288,11 +281,8 @@ export const editorialStyles = (
alignItems: "flex-end", alignItems: "flex-end",
height: 40, height: 40,
}, },
bar: (kind, heightPct, severity = 1) => ({ bar: (kind, heightPct) => ({
background: background: barBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
minHeight: 3, minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1, opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`, height: `${heightPct}%`,
@@ -1,14 +1,12 @@
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system"; import { keyframes } from "@mui/system";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens"; import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import type { BarKind, HeatCellKind } from "../shared/ChartCells"; export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
export type ModernHeatCell = HeatCellKind;
export type ModernBarKind = BarKind;
export type ModernGaugeFill = "ok" | "warm" | "hot"; export type ModernGaugeFill = "ok" | "warm" | "hot";
export interface ModernStyles { export interface ModernStyles {
@@ -37,9 +35,9 @@ export interface ModernStyles {
monitorUrl: SxProps<Theme>; monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>; badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>; heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>; heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>; histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>; bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>; chartStats: SxProps<Theme>;
infra: SxProps<Theme>; infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>; infraEmpty: SxProps<Theme>;
@@ -83,20 +81,17 @@ export const modernStyles = (
): ModernStyles => { ): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow; const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover; const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
const heatCellBg: Record<ModernHeatCell, string> = { const heatCellBg: Record<ModernHeatCell, string> = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`, down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
empty: tokens.border, empty: tokens.border,
}; };
const barBg: Record<ModernBarKind, string> = { const barBg: Record<ModernBarKind, string> = {
up: tokens.up, up: tokens.up,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
@@ -104,7 +99,7 @@ export const modernStyles = (
const gaugeFillBg: Record<ModernGaugeFill, string> = { const gaugeFillBg: Record<ModernGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`, ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`, warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`, hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
}; };
return { return {
@@ -355,19 +350,13 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" }, gap: { xs: "1px", md: "3px" },
height: 46, height: 46,
}, },
heatmapCell: (kind, severity = 1) => { heatmapCell: (kind) => ({
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity); borderRadius: "3px",
return { background: heatCellBg[kind],
borderRadius: "3px", opacity: kind === "empty" ? 0.4 : 1,
background: transition: "transform 0.15s",
kind === "degraded" "&:hover": { transform: "scaleY(1.2)" },
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})` }),
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
};
},
histogram: { histogram: {
padding: { xs: "0 12px", md: "0 24px" }, padding: { xs: "0 12px", md: "0 24px" },
@@ -377,11 +366,8 @@ export const modernStyles = (
alignItems: "flex-end", alignItems: "flex-end",
height: 46, height: 46,
}, },
bar: (kind, heightPct, severity = 1) => ({ bar: (kind, heightPct) => ({
background: background: barBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
borderRadius: "3px", borderRadius: "3px",
minHeight: 4, minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1, opacity: kind === "empty" ? 0.4 : 1,
@@ -1,12 +1,10 @@
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens"; import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import type { BarKind, HeatCellKind } from "../shared/ChartCells"; export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
export type RefinedHeatCell = HeatCellKind;
export type RefinedBarKind = BarKind;
export type RefinedGaugeFill = "ok" | "warm" | "hot"; export type RefinedGaugeFill = "ok" | "warm" | "hot";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -37,9 +35,9 @@ export interface RefinedStyles {
monitorUrl: SxProps<Theme>; monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>; badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>; heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>; heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>; histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>; bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>; chartStats: SxProps<Theme>;
infra: SxProps<Theme>; infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>; infraEmpty: SxProps<Theme>;
@@ -73,14 +71,12 @@ export const refinedStyles = (
fast: tokens.up, fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
slow: tokens.warn, slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
const barBg: Record<RefinedBarKind, string> = { const barBg: Record<RefinedBarKind, string> = {
up: tokens.up, up: tokens.up,
degraded: tokens.degraded,
down: tokens.down, down: tokens.down,
empty: tokens.border, empty: tokens.border,
}; };
@@ -296,12 +292,9 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" }, gap: { xs: "1px", md: "3px" },
height: 42, height: 42,
}, },
heatmapCell: (kind, severity = 1) => ({ heatmapCell: (kind) => ({
borderRadius: "2px", borderRadius: "2px",
background: background: heatCellBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1, opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s", transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" }, "&:hover": { transform: "scaleY(1.15)" },
@@ -315,11 +308,8 @@ export const refinedStyles = (
alignItems: "flex-end", alignItems: "flex-end",
height: 42, height: 42,
}, },
bar: (kind, heightPct, severity = 1) => ({ bar: (kind, heightPct) => ({
background: background: barBg[kind],
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
borderRadius: "2px", borderRadius: "2px",
minHeight: 3, minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1, opacity: kind === "empty" ? 0.4 : 1,
@@ -1,19 +1,20 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { StatusPage, StatusPageRange } from "@/Types/StatusPage"; import type { StatusPage } from "@/Types/StatusPage";
import { import { getMonitorTypeLabel } from "@/Types/StatusPage";
getMonitorTypeLabel,
STATUS_PAGE_RANGE_DAYS,
STATUS_PAGE_RANGES,
} from "@/Types/StatusPage";
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; import {
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; ThemedHeatmap,
type HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import {
ThemedHistogram,
type BarKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import { import {
ThemedInfrastructure, ThemedInfrastructure,
type GaugeFillLevel, type GaugeFillLevel,
@@ -25,22 +26,10 @@ import {
resolveOverallStatus, resolveOverallStatus,
statusBadgeKey, statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { formatPercentage } from "@/Utils/FormatUtils";
import {
checksToCells,
dailyBucketsToCells,
type BarKind,
type HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Icon } from "@/Components/design-elements";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { useMemo, useState } from "react"; import { formatPercentage } from "@/Utils/FormatUtils";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
type StatusPageMonitor = Monitor; type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
export interface BaseStyles { export interface BaseStyles {
page: SxProps<Theme>; page: SxProps<Theme>;
@@ -59,9 +48,9 @@ export interface BaseStyles {
monitorUrl: SxProps<Theme>; monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>; badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>; heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>; heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>; histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>; bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>; chartStats: SxProps<Theme>;
infra: SxProps<Theme>; infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>; infraEmpty: SxProps<Theme>;
@@ -93,22 +82,9 @@ interface Props {
statusPage: StatusPage; statusPage: StatusPage;
monitors: StatusPageMonitor[]; monitors: StatusPageMonitor[];
config: ThemeConfig<any>; config: ThemeConfig<any>;
range: StatusPageRange;
onRangeChange: (range: StatusPageRange) => void;
bucketTimezone: string;
checkTTLDays?: number;
} }
export const BaseStatusPage = ({ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
statusPage,
monitors,
config,
range,
onRangeChange,
bucketTimezone,
checkTTLDays,
}: Props) => {
const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme(); const { tokens, mode } = useStatusPageTheme();
const styles = useMemo( const styles = useMemo(
@@ -150,75 +126,33 @@ export const BaseStatusPage = ({
/> />
{statusPage.showCharts && ( {statusPage.showCharts && (
<Stack> <Box sx={styles.chartSwitchWrap}>
<Box sx={styles.chartSwitchWrap}> <Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box <Box
sx={styles.chartSwitch} component="button"
role="radiogroup" type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
> >
<Box {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
component="button" </Box>
type="button" <Box
role="radio" component="button"
aria-checked={chartMode === "heatmap"} type="button"
onClick={() => setChartMode("heatmap")} role="radio"
sx={styles.chartSwitchButton(chartMode === "heatmap")} aria-checked={chartMode === "histogram"}
> onClick={() => setChartMode("histogram")}
{t("pages.statusPages.monitorsList.chartTypeHeatmap")} sx={styles.chartSwitchButton(chartMode === "histogram")}
</Box> >
<Box {t("pages.statusPages.monitorsList.chartTypeHistogram")}
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box> </Box>
</Box> </Box>
<Box sx={styles.chartSwitchWrap}> </Box>
<Box
sx={styles.chartSwitch}
role="radiogroup"
aria-label={t("pages.statusPages.rangeSelectorAria")}
>
{STATUS_PAGE_RANGES.map((value) => (
<Box
component="button"
type="button"
role="radio"
key={value}
aria-checked={range === value}
onClick={() => onRangeChange(value)}
sx={styles.chartSwitchButton(range === value)}
>
{t(`pages.statusPages.range.${value}`)}
</Box>
))}
</Box>
</Box>
{range !== "latest" &&
checkTTLDays &&
STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && (
<Stack
mb={LAYOUT.XS}
alignSelf="flex-end"
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
<Icon
icon={TriangleAlert}
color={theme.palette.warning.main}
/>
<Typography>
{t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })}
</Typography>
</Stack>
)}
</Stack>
)} )}
<Stack <Stack
@@ -232,16 +166,6 @@ export const BaseStatusPage = ({
const badgeTone = monitorBadgeTone(monitor.status); const badgeTone = monitorBadgeTone(monitor.status);
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0); const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
const cells =
range === "latest"
? checksToCells(monitor.recentChecks ?? [])
: dailyBucketsToCells(
monitor.dailyChecks ?? [],
STATUS_PAGE_RANGE_DAYS[range],
bucketTimezone,
t
);
return ( return (
<Box <Box
component="li" component="li"
@@ -302,13 +226,13 @@ export const BaseStatusPage = ({
{showChart && {showChart &&
(chartMode === "heatmap" ? ( (chartMode === "heatmap" ? (
<ThemedHeatmap <ThemedHeatmap
cells={cells} checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap} containerSx={styles.heatmap}
cellSx={styles.heatmapCell} cellSx={styles.heatmapCell}
/> />
) : ( ) : (
<ThemedHistogram <ThemedHistogram
cells={cells} checks={monitor.recentChecks ?? []}
containerSx={styles.histogram} containerSx={styles.histogram}
barSx={styles.bar} barSx={styles.bar}
statsSx={styles.chartStats} statsSx={styles.chartStats}
@@ -1,137 +0,0 @@
import {
ThemedChartTooltip,
ThemedDailyTooltip,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import {
computeBarHeights,
computeDayBarHeights,
MIN_HEIGHT_PCT,
} from "@/Utils/DataUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { enumerateDays, formatMs } from "@/Utils/TimeUtils";
import type { TFunction } from "i18next";
export type BarKind = "up" | "degraded" | "down" | "empty";
export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empty";
export interface ChartCell {
key: string;
severity: number;
barKind: BarKind;
heatKind: HeatCellKind;
heightPct: number; // histogram bar height
responseTime: number; // 0 for empty cells
tooltip: React.ReactNode | null;
ariaLabel: string;
}
const DAY_DOWN_THRESHOLD = 0.5;
// Color interpolation
// 0 = perfect
// 1 = at or below the down threshold
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
export const severityFromUpRatio = (upRatio: number): number => {
const failed = 1 - upRatio;
if (failed <= 0) return 0;
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
const clamped = Math.min(1, Math.max(0, scaled));
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
};
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
if (bucket.totalChecks === 0) return "empty";
if (upRatio >= 1) return "up";
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
return "degraded";
};
export const classifyResponseTime = (
status: boolean,
responseTime: number | undefined
): Exclude<HeatCellKind, "empty"> => {
if (!status) return "down";
const rt = responseTime ?? 0;
if (rt > 500) return "slow";
if (rt > 250) return "med";
return "fast";
};
const emptyCell = (key: string): ChartCell => ({
key,
barKind: "empty",
heatKind: "empty",
heightPct: MIN_HEIGHT_PCT,
responseTime: 0,
tooltip: null,
ariaLabel: "",
severity: 0,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
...cells,
...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) =>
emptyCell(`empty-${cells.length + i}`)
),
];
export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
const source = checks.slice(-MAX_RECENT_CHECKS);
const heights = computeBarHeights(source);
const cells = source.map(
(check, i): ChartCell => ({
key: check.id ?? String(i),
barKind: check.status ? "up" : "down",
heatKind: classifyResponseTime(check.status, check.responseTime),
heightPct: heights[i] ?? MIN_HEIGHT_PCT,
responseTime: check.responseTime ?? 0,
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
};
export const dailyBucketsToCells = (
buckets: DailyCheckBucket[],
days: number,
bucketTimezone: string,
t: TFunction
): ChartCell[] => {
const byDate = new Map(buckets.map((b) => [b.date, b]));
const heights = computeDayBarHeights(buckets);
const enumerated = enumerateDays(days, bucketTimezone);
return enumerated.map((date) => {
const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio);
return {
key: `day-${date}`,
severity:
barKind === "degraded"
? severityFromUpRatio(upRatio)
: barKind === "down"
? 1
: 0,
barKind,
heatKind:
barKind === "up"
? classifyResponseTime(true, bucket.avgResponseTime ?? 0)
: barKind,
heightPct: heights.get(date) ?? MIN_HEIGHT_PCT,
responseTime: bucket.avgResponseTime ?? 0,
tooltip: <ThemedDailyTooltip bucket={bucket} />,
ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", {
date,
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
}),
};
});
};
@@ -1,64 +0,0 @@
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { useTranslation } from "react-i18next";
export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
return (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? formatMs(check.responseTime)
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
};
export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => {
const { t } = useTranslation();
return (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{bucket.date}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{t("pages.statusPages.monitorsList.chart.dayUptime", {
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
upChecks: bucket.upChecks.toLocaleString(),
totalChecks: bucket.totalChecks.toLocaleString(),
})}
</Typography>
{bucket.avgResponseTime !== null && (
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{t("pages.statusPages.monitorsList.chart.dayAvgResponse", {
value: formatMs(bucket.avgResponseTime),
})}
</Typography>
)}
</Stack>
);
};
@@ -1,49 +1,86 @@
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { import type { CheckSnapshot } from "@/Types/Check";
ChartCell, import { formatDateWithTz } from "@/Utils/TimeUtils";
HeatCellKind, import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
interface Props { interface Props {
cells: ChartCell[]; checks: CheckSnapshot[];
containerSx: SxProps<Theme>; containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>; cellSx: (kind: HeatCellKind) => SxProps<Theme>;
} }
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
if (!check.status) return "down";
const rt = check.responseTime ?? 0;
if (rt > 500) return "slow";
if (rt > 250) return "med";
return "fast";
};
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
];
return ( return (
<Box <Box
sx={[ sx={containerSx}
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
role="img" role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")} aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
> >
{cells.map((cell) => { {padded.map((check, i) => {
if (cell.heatKind === "empty") { if (!check) {
return ( return (
<Box <Box
key={cell.key} key={`empty-${i}`}
sx={cellSx("empty")} sx={cellSx("empty")}
/> />
); );
} }
const kind = classify(check);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
return ( return (
<Tooltip <Tooltip
key={cell.key} key={check.id ?? i}
title={cell.tooltip} title={tooltipContent}
arrow arrow
placement="top" placement="top"
> >
<Box <Box
sx={cellSx(cell.heatKind, cell.severity)} sx={cellSx(kind)}
aria-label={cell.ariaLabel} aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
/> />
</Tooltip> </Tooltip>
); );
@@ -2,67 +2,101 @@ import { useMemo } from "react";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMs } from "@/Utils/TimeUtils"; import type { CheckSnapshot } from "@/Types/Check";
import type { import { formatDateWithTz } from "@/Utils/TimeUtils";
BarKind, import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
ChartCell, import { useStatusPageTheme } from "../StatusPageThemeProvider";
} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; const CELLS = MAX_RECENT_CHECKS;
const MIN_HEIGHT_PCT = 6;
export type BarKind = "up" | "down" | "empty";
interface Props { interface Props {
cells: ChartCell[]; checks: CheckSnapshot[];
containerSx: SxProps<Theme>; containerSx: SxProps<Theme>;
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>; barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>; statsSx: SxProps<Theme>;
statsGap?: number; statsGap?: number;
} }
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
check.status ? "up" : "down";
export const ThemedHistogram = ({ export const ThemedHistogram = ({
cells, checks,
containerSx, containerSx,
barSx, barSx,
statsSx, statsSx,
statsGap = 1, statsGap = 1,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const { avg, peak } = useMemo(() => { const { padded, max, avg, peak } = useMemo(() => {
const valid = cells.filter((cell) => cell.responseTime > 0); const source = checks.slice(-CELLS);
const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0; const out: (CheckSnapshot | null)[] = [
...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
];
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
const avgRt = valid.length const avgRt = valid.length
? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
: 0; : 0;
return { avg: formatMs(avgRt), peak: formatMs(maxRt) }; return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
}, [cells]); }, [checks]);
return ( return (
<Stack gap={statsGap}> <Stack gap={statsGap}>
<Box <Box sx={containerSx}>
sx={[ {padded.map((check, i) => {
...(Array.isArray(containerSx) ? containerSx : [containerSx]), if (!check) {
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
>
{cells.map((cell) => {
if (cell.barKind === "empty") {
return ( return (
<Box <Box
key={cell.key} key={`empty-${i}`}
sx={barSx("empty", cell.heightPct)} sx={barSx("empty", MIN_HEIGHT_PCT)}
/> />
); );
} }
const height = Math.max(
MIN_HEIGHT_PCT,
Math.round((check.responseTime / max) * 100)
);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
timezone
)}
</Typography>
</Stack>
);
return ( return (
<Tooltip <Tooltip
key={cell.key} key={check.id ?? i}
title={cell.tooltip} title={tooltipContent}
arrow arrow
placement="top" placement="top"
> >
<Box <Box
sx={barSx(cell.barKind, cell.heightPct, cell.severity)} sx={barSx(tone(check), height)}
aria-label={cell.ariaLabel} aria-label={`${check.responseTime} ms`}
/> />
</Tooltip> </Tooltip>
); );
@@ -18,7 +18,7 @@ export interface InfraSx {
} }
interface Props { interface Props {
monitor: Monitor; monitor: Monitor & { checks?: Monitor["recentChecks"] };
sxApi: InfraSx; sxApi: InfraSx;
} }
@@ -36,8 +36,7 @@ const heatLevel = (value: number): GaugeFillLevel =>
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const recentChecks = monitor.recentChecks ?? []; const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
const latest = recentChecks[recentChecks.length - 1];
const renderEmpty = () => ( const renderEmpty = () => (
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box> <Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
@@ -243,10 +243,3 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
editorial, editorial,
minimal, minimal,
}; };
// Interpolate between a theme's up and degraded colors by severity (0..1).
export const mixSeverityColor = (
up: string,
degraded: string,
severity: number
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
+62 -46
View File
@@ -1,12 +1,12 @@
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { TextField, Button, ColorInput } from "@/Components/inputs";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useForm, FormProvider } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr"; import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
@@ -14,8 +14,6 @@ import type { TagFormData } from "@/Validation/tag";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTagsForm } from "@/Hooks/useTagsForm"; import { useTagsForm } from "@/Hooks/useTagsForm";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
const TagsCreatePage = () => { const TagsCreatePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,7 +34,7 @@ const TagsCreatePage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { reset, handleSubmit } = form; const { control, reset, handleSubmit } = form;
useEffect(() => { useEffect(() => {
reset(defaults); reset(defaults);
@@ -53,49 +51,67 @@ const TagsCreatePage = () => {
}; };
return ( return (
<FormProvider {...form}> <BasePage
<BasePage component="form"
component="form" onSubmit={handleSubmit(onSubmit)}
onSubmit={handleSubmit(onSubmit)} >
> <ConfigBox
<ConfigBox title={t("pages.tags.form.name.title")}
title={t("pages.tags.form.name.title")} subtitle={t("pages.tags.form.name.description")}
subtitle={t("pages.tags.form.name.description")} rightContent={
rightContent={ <Controller
<FormTextField name="name"
name="name" control={control}
fieldLabel={t("pages.tags.form.name.optionName")} defaultValue={defaults.name}
placeholder={t("pages.tags.form.name.placeholder")} render={({ field, fieldState }) => (
/> <TextField
} {...field}
/> type="text"
<ConfigBox fieldLabel={t("pages.tags.form.name.optionName")}
title={t("pages.tags.form.color.title")} placeholder={t("pages.tags.form.name.placeholder")}
subtitle={t("pages.tags.form.color.description")} fullWidth
rightContent={ error={!!fieldState.error}
<FormColorField helperText={fieldState.error?.message ?? ""}
name="color" />
fieldLabel={t("pages.tags.form.color.optionName")} )}
/> />
} }
/> />
<ConfigBox
title={t("pages.tags.form.color.title")}
subtitle={t("pages.tags.form.color.description")}
rightContent={
<Controller
name="color"
control={control}
defaultValue={defaults.color}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.tags.form.color.optionName")}
/>
)}
/>
}
/>
<Stack <Stack
direction="row" direction="row"
justifyContent="flex-end" justifyContent="flex-end"
spacing={theme.spacing(2)} spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
> >
<Button {t("common.buttons.save")}
loading={isSubmitting || isPatching} </Button>
type="submit" </Stack>
variant="contained" </BasePage>
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
); );
}; };
@@ -8,9 +8,10 @@ import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements"; import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck"; import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
export const GeoChecksTable = ({ export const GeoChecksTable = ({
geoChecks, geoChecks,
@@ -68,7 +69,7 @@ export const GeoChecksTable = ({
content: t("common.table.headers.responseTime"), content: t("common.table.headers.responseTime"),
render: (row) => { render: (row) => {
if (!row.timings?.total) return "N/A"; if (!row.timings?.total) return "N/A";
return formatMs(row.timings.total); return prettyMilliseconds(row.timings.total, { compact: true });
}, },
}, },
]; ];
+3 -40
View File
@@ -38,11 +38,6 @@ interface CertificateResponse {
certificateDate: string; certificateDate: string;
} }
interface DomainResponse {
domain: string;
expiryDate: string;
}
const UptimeDetailsPage = () => { const UptimeDetailsPage = () => {
const theme = useTheme(); const theme = useTheme();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
@@ -62,6 +57,7 @@ const UptimeDetailsPage = () => {
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("dateRange", dateRange); params.append("dateRange", dateRange);
params.append("normalize", "true");
return `/monitors/uptime/details/${monitorId}?${params.toString()}`; return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]); }, [monitorId, dateRange]);
@@ -76,7 +72,6 @@ const UptimeDetailsPage = () => {
); );
const monitorData = monitorDetailsData?.monitorData; const monitorData = monitorDetailsData?.monitorData;
const monitor = monitorData?.monitor; const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null; const monitorStats = monitorDetailsData?.monitorStats ?? null;
@@ -107,29 +102,6 @@ const UptimeDetailsPage = () => {
); );
}, [certificateData, uiTimezone]); }, [certificateData, uiTimezone]);
// Domain expiry fetch - only for HTTP monitors
const domainUrl = useMemo(() => {
if (!monitorId || monitor?.type !== "http") {
return null;
}
return `/monitors/domain/${monitorId}`;
}, [monitorId, monitor?.type]);
const { data: domainData } = useGet<DomainResponse>(
domainUrl,
{},
{ revalidateOnFocus: false }
);
const domainExpiry = useMemo(() => {
if (!domainData?.expiryDate) {
return undefined;
}
return (
formatDateWithTz(domainData.expiryDate, certificateDateFormat, uiTimezone) ?? "N/A"
);
}, [domainData, uiTimezone]);
const checksUrl = useMemo(() => { const checksUrl = useMemo(() => {
if (!monitorId || !monitor?.type) { if (!monitorId || !monitor?.type) {
return null; return null;
@@ -193,18 +165,10 @@ const UptimeDetailsPage = () => {
{ keepPreviousData: true, revalidateOnFocus: false } { keepPreviousData: true, revalidateOnFocus: false }
); );
if (!monitorData) {
return null;
}
if (!monitor) {
return null;
}
const geoChecksForTable = geoChecksTableData?.geoChecks ?? []; const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0; const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor.geoCheckLocations; const geoLocations = monitor?.geoCheckLocations;
const checks = checksData?.checks ?? []; const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0; const checksCount = checksData?.checksCount ?? 0;
@@ -221,7 +185,6 @@ const UptimeDetailsPage = () => {
monitor={monitor} monitor={monitor}
monitorStats={monitorStats} monitorStats={monitorStats}
certificateExpiry={certificateExpiry} certificateExpiry={certificateExpiry}
domainExpiry={domainExpiry}
/> />
<HeaderTimeRange <HeaderTimeRange
isLoading={monitorIsLoading || checksIsLoading} isLoading={monitorIsLoading || checksIsLoading}
@@ -252,7 +215,7 @@ const UptimeDetailsPage = () => {
/> />
</Stack> </Stack>
<HistogramDetails <HistogramDetails
checks={monitorData.groupedChecks} checks={monitorData?.groupedChecks || []}
range={dateRange} range={dateRange}
/> />
<ChecksTable <ChecksTable
+4 -17
View File
@@ -39,14 +39,11 @@ import CreateNotifications from "@/Pages/Notifications/create";
import Tags from "@/Pages/Tags"; import Tags from "@/Pages/Tags";
import CreateTags from "@/Pages/Tags/create"; import CreateTags from "@/Pages/Tags/create";
// Proxies
import Proxies from "@/Pages/Proxies";
import CreateProxies from "@/Pages/Proxies/create";
// Settings // Settings
import Account from "@/Pages/Account"; import Account from "@/Pages/Account";
import EditUser from "@/Pages/Account/EditUser"; import EditUser from "@/Pages/Account/EditUser";
import Settings from "@/Pages/Settings"; import Settings from "@/Pages/Settings";
import Maintenance from "@/Pages/Maintenance"; import Maintenance from "@/Pages/Maintenance";
import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create";
@@ -55,8 +52,10 @@ import Logs from "@/Pages/Logs";
// Routing // Routing
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected"; import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor"; import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl"; import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => { const Routes = () => {
if (isCustomDomainHost()) { if (isCustomDomainHost()) {
return ( return (
@@ -189,24 +188,12 @@ const Routes = () => {
path="tags/create" path="tags/create"
element={<CreateTags />} element={<CreateTags />}
/> />
<Route <Route
path="tags/configure/:tagId" path="tags/configure/:tagId"
element={<CreateTags />} element={<CreateTags />}
/> />
<Route
path="proxies"
element={<Proxies />}
/>
<Route
path="proxies/create"
element={<CreateProxies />}
/>
<Route
path="proxies/configure/:proxyId"
element={<CreateProxies />}
/>
<Route <Route
path="maintenance" path="maintenance"
element={<Maintenance />} element={<Maintenance />}
+10 -54
View File
@@ -149,15 +149,6 @@ export interface GroupedCheck {
totalChecks: number; totalChecks: number;
} }
export interface GroupedUptimeCheck extends GroupedCheck {
avgDns: number;
avgTcp: number;
avgTls: number;
avgRequest: number;
avgFirstByte: number;
avgDownload: number;
}
export interface PageSpeedGroupedCheck { export interface PageSpeedGroupedCheck {
bucketDate: string; bucketDate: string;
performance: number; performance: number;
@@ -250,53 +241,18 @@ export interface ChecksSummary {
downChecks: number; downChecks: number;
} }
export type SnapshotCpuInfo = Pick< export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
CheckCpuInfo, originalResponseTime: number;
| "physical_core"
| "logical_core"
| "frequency"
| "current_frequency"
| "temperature"
| "usage_percent"
>;
export type SnapshotMemoryInfo = Pick<
CheckMemoryInfo,
"total_bytes" | "used_bytes" | "usage_percent"
>;
export type SnapshotDiskInfo = Pick<
CheckDiskInfo,
"device" | "total_bytes" | "used_bytes" | "usage_percent"
>;
export type SnapshotHostInfo = Pick<CheckHostInfo, "os" | "platform" | "pretty_name">;
export type CheckSnapshot = Pick<
Check,
| "id"
| "status"
| "responseTime"
| "statusCode"
| "message"
| "createdAt"
| "accessibility"
| "bestPractices"
| "seo"
| "performance"
| "audits"
> & {
cpu?: SnapshotCpuInfo;
memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo;
}; };
export interface HasResponseTime { export interface HasResponseTime {
responseTime: number; responseTime: number;
} }
export interface DailyCheckBucket { export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
monitorId: string; originalResponseTime: number;
date: string; };
totalChecks: number;
upChecks: number; export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
downChecks: number; originalAvgResponseTime: number;
avgResponseTime: number | null; // null when no check that day recorded a response time };
}
+3 -67
View File
@@ -1,9 +1,4 @@
import type { import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
GroupedCheck,
CheckSnapshot,
GroupedUptimeCheck,
DailyCheckBucket,
} from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck"; import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck"; export type { GeoContinent } from "@/Types/GeoCheck";
@@ -25,56 +20,6 @@ export const MonitorTypes = [
] as const; ] as const;
export type MonitorType = (typeof MonitorTypes)[number]; export type MonitorType = (typeof MonitorTypes)[number];
// The subset offered by the CreateMonitor type selector, in display order.
// pagespeed/hardware are set by their own pages; unknown is never selectable.
export const SelectableMonitorTypes = [
"http",
"ping",
"docker",
"port",
"game",
"grpc",
"websocket",
"dns",
] as const;
export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number];
// Irregular i18n key fragments shared by the type selector's label
// (pages.common.monitors.monitorTypes.<fragment>), its description
// (pages.createMonitor.form.type.<fragment>Description), and status page
// type labels (via getMonitorTypeLabel). Covers every type with a label;
// only selectable types have description keys.
export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
Partial<Record<MonitorType, string>> = {
http: "optionHttp",
ping: "optionPing",
docker: "optionDocker",
port: "optionPort",
game: "optionGame",
grpc: "optionGrpc",
websocket: "optionWebSocket",
dns: "optionDns",
hardware: "optionHardware",
pagespeed: "optionPagespeed",
};
export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" },
{ value: 60000, labelKey: "oneMinute" },
{ value: 120000, labelKey: "twoMinutes" },
{ value: 180000, labelKey: "threeMinutes" },
{ value: 240000, labelKey: "fourMinutes" },
{ value: 300000, labelKey: "fiveMinutes" },
{ value: 600000, labelKey: "tenMinutes" },
{ value: 900000, labelKey: "fifteenMinutes" },
{ value: 1800000, labelKey: "thirtyMinutes" },
] as const;
export const GeoCheckIntervalOptions = MonitorIntervalOptions.filter(
({ value }) => value >= 300000
);
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
export type DnsRecordType = (typeof DnsRecordTypes)[number]; export type DnsRecordType = (typeof DnsRecordTypes)[number];
@@ -97,9 +42,7 @@ export const MonitorStatuses = [
] as const; ] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number]; export type MonitorStatus = (typeof MonitorStatuses)[number];
export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
export const DefaultMonitorMatchMethod: MonitorMatchMethod = "equal";
export const PageSpeedStrategies = ["desktop", "mobile"] as const; export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
@@ -109,10 +52,6 @@ export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number]; export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET"; export const DefaultHttpMethod: HttpMethod = "GET";
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const DefaultProxyMode: ProxyMode = "inherit";
export interface Monitor { export interface Monitor {
id: string; id: string;
userId: string; userId: string;
@@ -125,8 +64,6 @@ export interface Monitor {
statusWindowThreshold: number; statusWindowThreshold: number;
type: MonitorType; type: MonitorType;
ignoreTlsErrors: boolean; ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean; useAdvancedMatching: boolean;
jsonPath?: string; jsonPath?: string;
expectedValue?: string; expectedValue?: string;
@@ -160,7 +97,6 @@ export interface Monitor {
dnsServer?: string; dnsServer?: string;
dnsRecordType?: DnsRecordType; dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
dailyChecks?: DailyCheckBucket[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -201,7 +137,7 @@ export interface MonitorStats {
export interface MonitorData { export interface MonitorData {
monitor: Monitor; monitor: Monitor;
groupedChecks: GroupedUptimeCheck[]; groupedChecks: GroupedCheck[];
groupedUpChecks: GroupedCheck[]; groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number; groupedAvgResponseTime: number;
-1
View File
@@ -3,7 +3,6 @@ export const NotificationChannels = [
"slack", "slack",
"discord", "discord",
"webhook", "webhook",
"rocket_chat",
"pager_duty", "pager_duty",
"matrix", "matrix",
"teams", "teams",
-17
View File
@@ -1,17 +0,0 @@
export const ProxyProtocols = ["http", "https"] as const;
export type ProxyProtocol = (typeof ProxyProtocols)[number];
export interface Proxy {
id: string;
teamId: string;
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username?: string;
password?: string;
createdAt: string;
updatedAt: string;
}
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
-8
View File
@@ -24,8 +24,6 @@ export interface Settings {
showURL: boolean; showURL: boolean;
singleton: boolean; singleton: boolean;
globalThresholds?: SettingsThresholds; globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -33,11 +31,5 @@ export interface Settings {
export interface AppSettingsResponse { export interface AppSettingsResponse {
pagespeedKeySet: boolean; pagespeedKeySet: boolean;
emailPasswordSet: boolean; emailPasswordSet: boolean;
globalProxy: {
id: string;
name: string;
host: string;
port: number;
} | null;
settings: Settings; settings: Settings;
} }
+14 -20
View File
@@ -1,13 +1,24 @@
import { monitorTypeLabelKey } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure"; export type MonitorDisplayType = "uptime" | "infrastructure";
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
ping: "pages.common.monitors.monitorTypes.optionPing",
docker: "pages.common.monitors.monitorTypes.optionDocker",
port: "pages.common.monitors.monitorTypes.optionPort",
game: "pages.common.monitors.monitorTypes.optionGame",
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
hardware: "pages.common.monitors.monitorTypes.optionHardware",
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
};
export const getMonitorTypeLabel = ( export const getMonitorTypeLabel = (
type: MonitorType, type: MonitorType,
t: (key: string) => string t: (key: string) => string
): string => { ): string => {
const fragment = monitorTypeLabelKey[type]; const key = MONITOR_TYPE_KEYS[type];
return fragment ? t(`pages.common.monitors.monitorTypes.${fragment}`) : type; return key ? t(key) : type;
}; };
export const STATUS_PAGE_THEMES = [ export const STATUS_PAGE_THEMES = [
@@ -24,20 +35,6 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
export const STATUS_PAGE_DAY_RANGES = ["30d", "60d", "90d"] as const;
export type StatusPageDayRange = (typeof STATUS_PAGE_DAY_RANGES)[number];
export const STATUS_PAGE_RANGES = ["latest", ...STATUS_PAGE_DAY_RANGES] as const;
export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number];
export const STATUS_PAGE_RANGE_DAYS: Record<StatusPageDayRange, number> = {
"30d": 30,
"60d": 60,
"90d": 90,
};
export const isStatusPageRange = (value: string | null): value is StatusPageRange =>
STATUS_PAGE_RANGES.some((range) => range === value);
export const resolveStatusPageTheme = ( export const resolveStatusPageTheme = (
value: string | null | undefined value: string | null | undefined
): StatusPageTheme => ): StatusPageTheme =>
@@ -81,7 +78,4 @@ export interface StatusPage {
export interface StatusPageResponse { export interface StatusPageResponse {
statusPage: StatusPage; statusPage: StatusPage;
monitors: Monitor[]; monitors: Monitor[];
range?: StatusPageDayRange; // present only when range !== "latest"
bucketTimezone?: string;
checkTTLDays?: number;
} }
+44 -50
View File
@@ -1,3 +1,47 @@
const MIN_OUT = 10;
const MAX_OUT = 100;
export const normalizeResponseTimes = <T, K extends keyof T>(
checks: T[],
key: K
): (T & { normalResponseTime: number })[] => {
if (!Array.isArray(checks) || checks.length === 0)
return checks as (T & {
normalResponseTime: number;
})[];
if (checks.length === 1) {
return [
{
...checks[0],
normalResponseTime: 50,
},
];
}
const getVal = (check: T): number => {
const v = check[key] as unknown;
return typeof v === "number" && Number.isFinite(v) ? v : 0;
};
const { min, max } = checks.reduce(
(acc, check) => {
const v = getVal(check);
if (v > acc.max) acc.max = v;
if (v < acc.min) acc.min = v;
return acc;
},
{ max: -Infinity, min: Infinity }
);
const range = max - min || 1;
return checks.map((check) => ({
...check,
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
}));
};
// Interpolate color between three theme colors over 0-100 range. // Interpolate color between three theme colors over 0-100 range.
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light) // 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
export const getResponseColor = ( export const getResponseColor = (
@@ -85,53 +129,3 @@ export const getResponseColor = (
} }
return normalizeToHex(safe.end); return normalizeToHex(safe.end);
}; };
export const MIN_HEIGHT_PCT = 8;
const CAP_PERCENTILE = 95;
const percentile = (sortedValues: number[], p: number): number => {
const index = (p / 100) * (sortedValues.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= sortedValues.length) return sortedValues[lower];
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
};
export const computeBarHeights = (
checks: { responseTime?: number | null }[]
): number[] => {
const values = checks.map((check) => {
return typeof check.responseTime === "number" && Number.isFinite(check.responseTime)
? Math.max(0, check.responseTime)
: 0;
});
const sorted = values.slice().sort((a, b) => a - b);
const cap = percentile(sorted, CAP_PERCENTILE);
if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT);
return values.map((value) => {
return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100));
});
};
export const computeDayBarHeights = (
buckets: { date: string; avgResponseTime: number | null }[]
): Map<string, number> => {
const values = buckets.map((bucket) =>
typeof bucket.avgResponseTime === "number" && Number.isFinite(bucket.avgResponseTime)
? Math.max(0, bucket.avgResponseTime)
: 0
);
const sorted = values.slice().sort((a, b) => a - b);
const cap = percentile(sorted, CAP_PERCENTILE);
return new Map(
buckets.map((bucket, i) => [
bucket.date,
cap <= 0
? MIN_HEIGHT_PCT
: Math.max(MIN_HEIGHT_PCT, Math.min(100, (values[i] / cap) * 100)),
])
);
};
+11 -17
View File
@@ -1,31 +1,25 @@
/** /**
* Intl.NumberFormat instance for percentage formatting. * Intl.NumberFormat instance for percentage formatting.
* Reused across all formatting calls for performance. * Reused across all formatting calls for performance.
*
* Two decimals rather than one: at a single decimal every value above 99.95%
* renders as "100.0%", so a period containing real downtime is reported as
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
* different SLA commitments — into the same figure.
*/ */
const percentageFormatter = new Intl.NumberFormat("en-US", { const percentageFormatter = new Intl.NumberFormat("en-US", {
style: "percent", style: "percent",
minimumFractionDigits: 2, minimumFractionDigits: 1,
maximumFractionDigits: 2, maximumFractionDigits: 1,
}); });
/** /**
* Formats a decimal value as a percentage string. * Formats a decimal value as a percentage string.
* @param value - Decimal value (e.g., 0.75 for 75%) * @param value - Decimal value (e.g., 0.75 for 75%)
* @returns Formatted percentage string (e.g., "75.00%") * @returns Formatted percentage string (e.g., "75.0%")
* @example * @example
* formatPercentage(0.75) // "75.00%" * formatPercentage(0.75) // "75.0%"
* formatPercentage(1) // "100.00%" * formatPercentage(1) // "100.0%"
* formatPercentage(0.5432) // "54.32%" * formatPercentage(0.5432) // "54.3%"
* formatPercentage(0.9999) // "99.99%"
*/ */
export const formatPercentage = (value: number): string => { export const formatPercentage = (value: number): string => {
if (typeof value !== "number" || Number.isNaN(value)) { if (typeof value !== "number" || Number.isNaN(value)) {
return "0.00%"; return "0.0%";
} }
return percentageFormatter.format(value); return percentageFormatter.format(value);
}; };
@@ -33,11 +27,11 @@ export const formatPercentage = (value: number): string => {
/** /**
* Formats a whole number percentage value as a percentage string. * Formats a whole number percentage value as a percentage string.
* @param value - Whole number percentage (e.g., 75 for 75%) * @param value - Whole number percentage (e.g., 75 for 75%)
* @returns Formatted percentage string (e.g., "75.00%") * @returns Formatted percentage string (e.g., "75.0%")
* @example * @example
* formatPercentageFromWhole(75) // "75.00%" * formatPercentageFromWhole(75) // "75.0%"
* formatPercentageFromWhole(100) // "100.00%" * formatPercentageFromWhole(100) // "100.0%"
* formatPercentageFromWhole(54.32) // "54.32%" * formatPercentageFromWhole(54.32) // "54.3%"
*/ */
export const formatPercentageFromWhole = (value: number): string => { export const formatPercentageFromWhole = (value: number): string => {
return formatPercentage(value / 100); return formatPercentage(value / 100);
-31
View File
@@ -20,19 +20,8 @@ export const colors = {
brandGreen: "#13715B", brandGreen: "#13715B",
brandGreenLight: "#4DAF94", brandGreenLight: "#4DAF94",
green200: "#ECF7F2", green200: "#ECF7F2",
green700: "#008300",
yellow200: "#FFF4E5", yellow200: "#FFF4E5",
yellow500: "#EDA100",
yellow600: "#C98500",
orange500: "#FFA500", orange500: "#FFA500",
orange600: "#EB6834",
orange700: "#D95926",
blue400: "#3987E5",
blue500: "#2A78D6",
aqua500: "#1BAF7A",
aqua600: "#199E70",
magenta300: "#E87BA4",
magenta400: "#D55181",
}; };
export const lightPalette = { export const lightPalette = {
@@ -49,16 +38,6 @@ export const lightPalette = {
running: colors.green200, running: colors.green200,
paused: colors.yellow200, paused: colors.yellow200,
}, },
chart: {
phases: {
dns: colors.magenta300,
tcp: colors.orange600,
tls: colors.aqua500,
request: colors.yellow500,
firstByte: colors.blue500,
download: colors.green700,
},
},
}; };
export const darkPalette = { export const darkPalette = {
@@ -79,14 +58,4 @@ export const darkPalette = {
running: alpha(colors.brandGreen, 0.18), running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18), paused: alpha(colors.orange500, 0.18),
}, },
chart: {
phases: {
dns: colors.magenta400,
tcp: colors.orange700,
tls: colors.aqua600,
request: colors.yellow600,
firstByte: colors.blue500,
download: colors.green700,
},
},
}; };
-28
View File
@@ -16,30 +16,10 @@ declare module "@mui/material/styles" {
interface Palette { interface Palette {
sidebar: { accent: string }; sidebar: { accent: string };
rowStatus: { running: string; paused: string }; rowStatus: { running: string; paused: string };
chart: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
} }
interface PaletteOptions { interface PaletteOptions {
sidebar?: { accent: string }; sidebar?: { accent: string };
rowStatus?: { running: string; paused: string }; rowStatus?: { running: string; paused: string };
chart?: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
} }
} }
@@ -124,14 +104,6 @@ export const theme = (mode: string, palette: any) =>
disableTouchRipple: true, disableTouchRipple: true,
disableFocusRipple: true, disableFocusRipple: true,
}, },
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-focusVisible": {
outline: `1px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
},
}),
},
}, },
MuiIconButton: { MuiIconButton: {
defaultProps: { defaultProps: {
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff. Show more