Compare commits

..
Author SHA1 Message Date
gorkem-bwl 6f3de9e064 docs: sync translated READMEs with recent English changes, move docs links to checkmate.so
Port the recent English README updates to all 17 translated READMEs
(ar, ca, cs, de, es, fi, fr, it, ja, pt-BR, ru, th, tr, uk, vi,
zh-CN, zh-TW):

- Contributing section: remove Karen from the team list and update
  the stats to almost 11K stars and 150+ contributors (7dedcca41)
- Header markup: wrap the PikaPods badge and hero image inside the
  centered paragraph, matching the English README (7dedcca41)
- Installation: add the translated 1-click installation options
  paragraph (Repocloud, Pikapods, Coolify, Elestio, K8s, Sive Host,
  Cloudzy) (2e258fa39)

The MongoDB "all-in-one" clarification (94f1f7e57) could not be
ported because the translations predate the Docker Compose
installation section it amends.

Also point the Custom CA Trust Guide link at
https://checkmate.so/docs/settings/custom-ca-trust and the docs
directory link at https://checkmate.so/docs, in the English README
and all translations.

Claude-Session: https://claude.ai/code/session_013QM7MWiBTNUQzzPC1xh72V
2026-09-02 20:48:41 -04:00
344 changed files with 6329 additions and 14211 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
- name: Install client dependencies - name: Install client dependencies
working-directory: client working-directory: client
@@ -29,7 +29,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
- name: Install server dependencies - name: Install server dependencies
working-directory: server working-directory: server
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
- name: Install client dependencies - name: Install client dependencies
working-directory: client working-directory: client
@@ -30,7 +30,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
- name: Install server dependencies - name: Install server dependencies
working-directory: server working-directory: server
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
- name: Install server dependencies - name: Install server dependencies
working-directory: server working-directory: server
+1 -1
View File
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
### Monorepo Structure ### Monorepo Structure
- `/client` - React 18 + TypeScript + Vite + MUI frontend - `/client` - React 18 + TypeScript + Vite + MUI frontend
- `/server` - Node.js 24+ + Express + TypeScript backend - `/server` - Node.js 20+ + Express + TypeScript backend
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono) - `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
### Backend Layers ### Backend Layers
+13 -130
View File
@@ -36,7 +36,8 @@
[![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="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p> </p>
<br> <br>
@@ -92,7 +93,7 @@ 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), 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), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1. [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
@@ -104,15 +105,7 @@ The image is configured entirely through environment variables on the server con
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` | | `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` | | `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails | | `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
| `PORT` | No | Port the API and web client are served on (default `52345`) |
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) | | `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables: The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
@@ -126,123 +119,13 @@ The web client needs no configuration by default: it calls the API on the same o
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs). See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA ### Using a Custom CA
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options. If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](https://checkmate.so/docs/settings/custom-ca-trust) for Docker configuration options.
### Docker monitors For more documentation, see the [Checkmate documentation](https://checkmate.so/docs).
A Docker monitor reports on every container running on a host. It can connect to the Docker daemon directly or read container metrics from a Capture agent. Direct monitors use the daemon's ping response for availability and record container state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every direct check; logs are kept for 7 days.
The **Docker host** field accepts three forms:
| Host | Example | Notes |
|---|---|---|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
| Capture agent | `https://capture.example.com/api/v1/metrics/docker` | Enter the full endpoint ending in `/metrics/docker` and its authorization secret. Checkmate requests `all=true` so stopped containers are included. |
Capture-backed monitors normalize Capture's container state, health, CPU, memory, start time and exposed ports into the same Docker pages. Capture does not currently provide restart counts, mounts, published host-port bindings or container logs, so those values are shown as unavailable. Capture also reports a container whose health check is still starting as healthy, so the `starting` health state only appears on direct monitors, and stopped containers carry no CPU or memory metrics.
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
```yaml
services:
checkmate:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "989" # the gid printed by stat
```
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
### Securing the Docker daemon with TLS
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
```bash
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
```
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
```bash
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
cat > server-ext.cnf <<EOF
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
```
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
```bash
openssl genrsa -out key.pem 4096
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
rm client.csr server.csr server-ext.cnf client-ext.cnf
chmod 0400 ca-key.pem key.pem server-key.pem
chmod 0444 ca.pem server-cert.pem cert.pem
```
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/certs/ca.pem",
"tlscert": "/etc/docker/certs/server-cert.pem",
"tlskey": "/etc/docker/certs/server-key.pem"
}
```
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
```bash
sudo systemctl edit docker.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
```
```bash
sudo systemctl daemon-reload && sudo systemctl restart docker
```
Open port `2376` on the host firewall only to the machine running Checkmate.
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
```bash
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
-H=docker.example.com:2376 version
```
For more documentation, see the [docs directory](./docs/).
<a id="performance"></a> <a id="performance"></a>
## Performance ## Performance
@@ -251,7 +134,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) ![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9)
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers: You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) ![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
@@ -269,16 +152,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Features ## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5) - Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server - Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
- Page speed monitoring - Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent - Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection - Selective disk monitoring with mountpoint selection
- Incidents at a glance - Incidents at a glance
- Status pages with 5 beautiful themes - Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications - E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance - Scheduled maintenance
- JSON query monitoring - JSON query monitoring
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese - 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
## Monitor Lifecycle ## Monitor Lifecycle
@@ -329,9 +212,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), [Malena](https://github.com/malenacaroline) 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) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 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 almost 11K+ stars and attracted 150+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us! 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 -12
View File
@@ -1,17 +1,16 @@
{ {
"name": "client", "name": "client",
"version": "3.12.0", "version": "3.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "client", "name": "client",
"version": "3.12.0", "version": "3.11.0",
"dependencies": { "dependencies": {
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0", "@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0", "@mui/lab": "7.0.0",
@@ -951,15 +950,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hapi/hoek": { "node_modules/@hapi/hoek": {
"version": "9.3.0", "version": "9.3.0",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
+2 -8
View File
@@ -1,11 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.12.0", "version": "3.11.0",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"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", "lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
@@ -16,8 +12,7 @@
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write .", "format": "prettier --write .",
"format-check": "prettier --check .", "format-check": "prettier --check ."
"compile": "tsc -b"
}, },
"lint-staged": { "lint-staged": {
"**/*": "prettier --write --ignore-unknown" "**/*": "prettier --write --ignore-unknown"
@@ -26,7 +21,6 @@
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0", "@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0", "@mui/lab": "7.0.0",
@@ -1,12 +1,10 @@
import Box, { type BoxProps } from "@mui/material/Box"; import Box, { type BoxProps } from "@mui/material/Box";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { forwardRef } from "react";
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => { export const BaseBox = ({ sx, ...rest }: BoxProps) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<Box <Box
ref={ref}
{...rest} {...rest}
sx={{ sx={{
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
@@ -18,6 +16,4 @@ export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, re
}} }}
/> />
); );
}); };
BaseBox.displayName = "BaseBox";
@@ -8,7 +8,7 @@ interface IconProps {
color?: string; color?: string;
} }
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => { const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
return ( return (
<Icon <Icon
size={size} size={size}
@@ -8,11 +8,8 @@ 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"; import { formatDuration } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme(); const theme = useTheme();
const { t, i18n } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!monitor) { if (!monitor) {
@@ -55,12 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<> <>
<Dot /> <Dot />
<Typography> <Typography>
{t("pages.common.monitors.statBoxes.checkingEvery", { Checking every {formatDuration(monitor?.interval, true)}
interval: formatDuration(monitor.interval, {
long: true,
locale: i18n.resolvedLanguage,
}),
})}
</Typography> </Typography>
</> </>
)} )}
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
left: 0, left: 0,
right: 0, right: 0,
zIndex: theme.zIndex.snackbar, zIndex: theme.zIndex.snackbar,
backgroundColor: theme.palette.error.dark, backgroundColor: theme.palette.error.main,
color: "#FFFFFF", color: theme.palette.error.contrastText,
px: theme.spacing(8), px: theme.spacing(8),
py: theme.spacing(4), py: theme.spacing(4),
transition: "top 1s ease-in-out", transition: "top 1s ease-in-out",
@@ -6,7 +6,6 @@ import { Link as RouterLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react"; import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -110,7 +109,7 @@ export const PageHeader = ({
fontSize: 14, fontSize: 14,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
lineHeight: 1.55, lineHeight: 1.55,
maxWidth: "108ch", maxWidth: 640,
}} }}
> >
{description} {description}
@@ -143,7 +142,7 @@ export const PageHeader = ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
width: 36, width: 36,
height: INPUT_BASE_HEIGHT, height: 32,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
borderRight: borderRight:
idx < linkItems.length - 1 idx < linkItems.length - 1
@@ -1,5 +1,4 @@
import { Box, Stack, useTheme } from "@mui/material"; import { Box, Stack, useTheme } from "@mui/material";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps { interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string }; width?: number | string | { xs?: number | string; md?: number | string };
@@ -45,7 +44,7 @@ export const SkeletonCard = ({
const blockStyle = { const blockStyle = {
width: 55, width: 55,
height: INPUT_BASE_HEIGHT, height: 34,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main, background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { darken, lighten } from "@mui/material/styles"; import { lighten } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { PaletteKey } from "@/Utils/Theme/Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements"; import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
@@ -19,10 +19,10 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light"; const isLight = theme.palette.mode === "light";
const paper = theme.palette.background.paper; const paper = theme.palette.background.paper;
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
const paperEnd = paper; const paperEnd = paper;
const bg = palette const bg = palette
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)` ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`; : `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
return ( return (
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material"; import type { SxProps } from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{ type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode; children: React.ReactNode;
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
sx={{ sx={{
pointerEvents: "none", pointerEvents: "none",
backgroundImage: ` backgroundImage: `
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px), linear-gradient(${theme.palette.divider} 1px, transparent 1px),
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px) linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
`, `,
backgroundSize: "24px 24px", backgroundSize: "24px 24px",
maskImage: maskImage:
@@ -52,7 +52,6 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
emptyViewText?: string; emptyViewText?: string;
emptyViewPositive?: boolean; emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps<Theme>; getRowSx?: (row: T) => SxProps<Theme>;
isRowSelected?: (row: T) => boolean;
}; };
export function DataTable< export function DataTable<
@@ -71,7 +70,6 @@ export function DataTable<
emptyViewText, emptyViewText,
emptyViewPositive, emptyViewPositive,
getRowSx, getRowSx,
isRowSelected,
}: DataTableProps<T>) { }: DataTableProps<T>) {
const theme = useTheme(); const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null); const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -81,21 +79,6 @@ export function DataTable<
}; };
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const hasTextSelectionWithin = (element: Element) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
return (
selection.anchorNode !== null &&
element.contains(
selection.anchorNode.nodeType === Node.TEXT_NODE
? selection.anchorNode.parentNode
: selection.anchorNode
)
);
};
const isInteractive = Boolean(onRowClick) || expandableRows; const isInteractive = Boolean(onRowClick) || expandableRows;
if (data.length === 0 || headers.length === 0) { if (data.length === 0 || headers.length === 0) {
@@ -117,10 +100,7 @@ export function DataTable<
keys.push(key); keys.push(key);
return ( return (
<Stack <Stack
onClick={(e) => { onClick={() => (onRowClick ? onRowClick(row) : null)}
if (hasTextSelectionWithin(e.currentTarget)) return;
if (onRowClick) onRowClick(row);
}}
spacing={theme.spacing(LAYOUT.XS)} spacing={theme.spacing(LAYOUT.XS)}
sx={{ sx={{
borderStyle: "solid", borderStyle: "solid",
@@ -217,7 +197,7 @@ export function DataTable<
"& :is(th)": { "& :is(th)": {
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontWeight: 600, fontWeight: 500,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: "0.08em", letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`, padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
@@ -233,6 +213,10 @@ export function DataTable<
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": { "& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none", borderBottom: "none",
}, },
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
{
backgroundColor: theme.palette.action.rowHover,
},
}} }}
> >
<TableHead> <TableHead>
@@ -253,29 +237,16 @@ export function DataTable<
{data.map((row) => { {data.map((row) => {
const key = row.id || row._id || Math.random(); const key = row.id || row._id || Math.random();
const isExpanded = expanded === key; const isExpanded = expanded === key;
const selected = isRowSelected?.(row);
return ( return (
<Fragment key={key}> <Fragment key={key}>
<TableRow <TableRow
className={isInteractive ? "is-clickable" : undefined}
sx={{ sx={{
cursor: isInteractive ? "pointer" : "default", cursor: isInteractive ? "pointer" : "default",
...(isInteractive && {
"&:hover .MuiTableCell-root": {
backgroundColor: selected
? theme.palette.action.selectedHover
: theme.palette.action.rowHover,
},
}),
...(selected && {
"& .MuiTableCell-root": {
backgroundColor: theme.palette.action.selected,
},
}),
...(getRowSx?.(row) as object), ...(getRowSx?.(row) as object),
}} }}
onClick={(e) => { onClick={() => {
if (hasTextSelectionWithin(e.currentTarget)) return;
if (expandableRows) handleExpand(row); if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row); else if (onRowClick) onRowClick(row);
}} }}
@@ -1,8 +1,6 @@
import MuiTabs from "@mui/material/Tabs"; import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs"; import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface CustomTabsProps extends TabsProps {} interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => { export const Tabs = (props: CustomTabsProps) => {
@@ -10,7 +8,7 @@ export const Tabs = (props: CustomTabsProps) => {
return ( return (
<MuiTabs <MuiTabs
sx={{ sx={{
minHeight: INPUT_BASE_HEIGHT, minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`, borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": { "& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main, backgroundColor: theme.palette.primary.main,
@@ -40,9 +38,9 @@ export const Tab = (props: CustomTabProps) => {
iconPosition="start" iconPosition="start"
sx={{ sx={{
textTransform: "none", textTransform: "none",
fontSize: typographyLevels.m, fontSize: 14,
fontWeight: 500, fontWeight: 500,
minHeight: INPUT_BASE_HEIGHT, minHeight: 34,
padding: theme.spacing(1, 0), padding: theme.spacing(1, 0),
paddingBottom: 0, paddingBottom: 0,
minWidth: "auto", minWidth: "auto",
@@ -50,6 +48,7 @@ export const Tab = (props: CustomTabProps) => {
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
"&.Mui-selected": { "&.Mui-selected": {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontWeight: 600,
}, },
"&:hover": { "&:hover": {
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
+2 -3
View File
@@ -1,7 +1,7 @@
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button"; import type { ButtonProps } from "@mui/material/Button";
import { useTheme, darken } from "@mui/material/styles"; import { useTheme, darken } from "@mui/material/styles";
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants"; import { HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [ const PALETTE_COLORS = [
"primary", "primary",
@@ -43,8 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props} {...props}
sx={{ sx={{
textTransform: "none", textTransform: "none",
height: INPUT_BASE_HEIGHT, height: 34,
lineHeight: 1.2,
fontWeight: 400, fontWeight: 400,
borderRadius: 2, borderRadius: 2,
whiteSpace: "nowrap", whiteSpace: "nowrap",
+1 -2
View File
@@ -4,7 +4,6 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/inputs/FieldLabel"; import { FieldLabel } from "@/Components/inputs/FieldLabel";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps { interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string; fieldLabel?: string;
@@ -19,7 +18,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
height: INPUT_BASE_HEIGHT, height: 34,
fontSize: typographyLevels.base, fontSize: typographyLevels.base,
}, },
"& .MuiOutlinedInput-notchedOutline": { "& .MuiOutlinedInput-notchedOutline": {
+2 -5
View File
@@ -8,7 +8,6 @@ import { FieldLabel } from "./FieldLabel";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
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";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> { interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string; fieldLabel?: string;
@@ -56,7 +55,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse", flexDirection: "row-reverse",
}, },
"& input": { "& input": {
minHeight: INPUT_BASE_HEIGHT, minHeight: 34,
p: 0, p: 0,
pl: theme.spacing(3), pl: theme.spacing(3),
pr: theme.spacing(5), pr: theme.spacing(5),
@@ -67,9 +66,7 @@ export const DatePickerComponent = ({
}, },
"&: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 borderColor: error ? theme.palette.error.main : theme.palette.divider,
? theme.palette.error.main
: theme.palette.text.disabled,
}, },
}, },
}, },
+5 -2
View File
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> { interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string; fieldLabel?: string;
@@ -72,7 +71,7 @@ const SelectInputInner = <T,>(
/> />
)} )}
sx={{ sx={{
height: `${INPUT_BASE_HEIGHT}px`, height: "34px",
"& .MuiSelect-select": { "& .MuiSelect-select": {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -84,6 +83,10 @@ const SelectInputInner = <T,>(
}, },
"& .MuiOutlinedInput-notchedOutline": { "& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
}, },
...props.sx, ...props.sx,
}} }}
+9 -3
View File
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
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 { FieldLabel } from "./FieldLabel"; import { FieldLabel } from "./FieldLabel";
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit<TextFieldProps, "label"> { interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string; fieldLabel?: string;
@@ -22,10 +22,16 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%", width: "100%",
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT, height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base, fontSize: typographyLevels.base,
overflow: "hidden", overflow: "hidden",
}, },
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": { "& .MuiFormHelperText-root": {
marginLeft: 0, marginLeft: 0,
marginRight: 0, marginRight: 0,
@@ -33,7 +39,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
}, },
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active": "& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{ {
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`, WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`, WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary, caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s", transition: "background-color 5000s ease-in-out 0s",
+1 -2
View File
@@ -7,7 +7,6 @@ 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";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> { interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string; fieldLabel?: string;
@@ -34,7 +33,7 @@ export const TimePickerComponent = ({
sx: { sx: {
width: "fit-content", width: "fit-content",
"& input": { "& input": {
minHeight: INPUT_BASE_HEIGHT, minHeight: 34,
p: 0, p: 0,
px: theme.spacing(5), px: theme.spacing(5),
}, },
@@ -4,7 +4,6 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup"; import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles"; import { alpha } from "@mui/material/styles";
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => { export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme(); const theme = useTheme();
@@ -21,7 +20,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{ sx={{
px: 8, px: 8,
textTransform: "none", textTransform: "none",
height: INPUT_BASE_HEIGHT, height: 34,
fontWeight: 400, fontWeight: 400,
whiteSpace: "nowrap", whiteSpace: "nowrap",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@@ -35,6 +34,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
"&.Mui-selected": { "&.Mui-selected": {
backgroundColor: selectedBg, backgroundColor: selectedBg,
color: theme.palette.text.primary, color: theme.palette.text.primary,
fontWeight: 500,
"&:hover": { "&:hover": {
backgroundColor: selectedHoverBg, backgroundColor: selectedHoverBg,
}, },
@@ -5,10 +5,9 @@ import {
type FieldValues, type FieldValues,
} from "react-hook-form"; } from "react-hook-form";
import { Autocomplete } from "@/Components/inputs"; import { Autocomplete } from "@/Components/inputs";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
export interface AutocompleteOption { export interface AutocompleteOption {
id: string | number; id: string;
name: string; name: string;
} }
@@ -17,9 +16,6 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
options: O[]; options: O[];
fieldLabel?: string; fieldLabel?: string;
placeholder?: string; placeholder?: string;
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
disableClearable?: boolean;
} }
export const FormAutocompleteField = < export const FormAutocompleteField = <
@@ -30,9 +26,6 @@ export const FormAutocompleteField = <
options, options,
fieldLabel, fieldLabel,
placeholder, placeholder,
filterOptions,
getOptionDisabled,
disableClearable,
}: FormAutocompleteProps<T, O>) => { }: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>(); const { control } = useFormContext<T>();
return ( return (
@@ -46,14 +39,10 @@ export const FormAutocompleteField = <
isOptionEqualToValue={(option, value) => option.id === value.id} isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null} value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => { onChange={(_: unknown, newValue: O | null) => {
if (newValue === null && disableClearable) return;
field.onChange(newValue?.id ?? ""); field.onChange(newValue?.id ?? "");
}} }}
fieldLabel={fieldLabel} fieldLabel={fieldLabel}
placeholder={placeholder} placeholder={placeholder}
filterOptions={filterOptions}
getOptionDisabled={getOptionDisabled}
disableClearable={disableClearable}
error={!!fieldState.error} error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""} helperText={fieldState.error?.message ?? ""}
/> />
@@ -60,22 +60,20 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }} direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)} gap={theme.spacing(2)}
> >
{isAdmin && ( <Button
<Button variant="contained"
variant="contained" color="secondary"
color="secondary" loading={isPosting}
loading={isPosting} startIcon={<Icon icon={Mail} />}
startIcon={<Icon icon={Mail} />} onClick={async () => {
onClick={async () => { await post(`/notifications/test/all`, { monitorId: monitor.id });
await post(`/notifications/test/all`, { monitorId: monitor.id }); }}
}} sx={{
sx={{ whiteSpace: "nowrap",
whiteSpace: "nowrap", }}
}} >
> {t("common.buttons.testNotifications")}
{t("common.buttons.testNotifications")} </Button>
</Button>
)}
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
@@ -35,7 +35,7 @@ export const MonitorListPage = ({
headerKey, headerKey,
page, page,
actionLink, actionLink,
controller, controller: c,
bulkActions, bulkActions,
showTypeFilter, showTypeFilter,
summaryProps, summaryProps,
@@ -48,14 +48,14 @@ export const MonitorListPage = ({
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const isLoading = controller.isLoading || Boolean(extraLoading); const isLoading = c.isLoading || Boolean(extraLoading);
return ( return (
<MonitorBasePageWithStates <MonitorBasePageWithStates
headerKey={headerKey} headerKey={headerKey}
loading={isLoading} loading={isLoading}
error={controller.error || extraError} error={c.error || extraError}
totalCount={controller.effectiveTotalCount} totalCount={c.effectiveTotalCount}
page={page} page={page}
actionLink={actionLink} actionLink={actionLink}
priorityFallback={priorityFallback} priorityFallback={priorityFallback}
@@ -66,7 +66,7 @@ export const MonitorListPage = ({
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
<HeaderMonitorsSummary <HeaderMonitorsSummary
summary={controller.summary} summary={c.summary}
{...summaryProps} {...summaryProps}
/> />
<Stack <Stack
@@ -78,27 +78,27 @@ export const MonitorListPage = ({
{...(showTypeFilter === false {...(showTypeFilter === false
? { showTypes: false } ? { showTypes: false }
: { : {
selectedTypes: controller.selectedTypes, selectedTypes: c.selectedTypes,
setSelectedTypes: controller.setSelectedTypes, setSelectedTypes: c.setSelectedTypes,
})} })}
selectedStatus={controller.selectedStatus} selectedStatus={c.selectedStatus}
setSelectedStatus={controller.setSelectedStatus} setSelectedStatus={c.setSelectedStatus}
selectedState={controller.selectedState} selectedState={c.selectedState}
setSelectedState={controller.setSelectedState} setSelectedState={c.setSelectedState}
tagOptions={controller.tags ?? []} tagOptions={c.tags ?? []}
selectedTags={controller.selectedTags} selectedTags={c.selectedTags}
setSelectedTags={controller.setSelectedTags} setSelectedTags={c.setSelectedTags}
onClearFilters={controller.handleClearFilters} onClearFilters={c.handleClearFilters}
/> />
<TextField <TextField
placeholder={t("pages.uptime.filters.search.placeholder")} placeholder={t("pages.uptime.filters.search.placeholder")}
value={controller.search} value={c.search}
onChange={(event) => { onChange={(event) => {
controller.setSearch(event.target.value); c.setSearch(event.target.value);
}} }}
/> />
</Stack> </Stack>
{controller.selectedTags.length > 0 && ( {c.selectedTags.length > 0 && (
<Stack <Stack
direction={isSmall ? "column" : "row"} direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"} alignItems={isSmall ? "flex-start" : "center"}
@@ -108,8 +108,8 @@ export const MonitorListPage = ({
<Typography color={theme.palette.text.secondary}> <Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")} {t("pages.uptime.filters.activeTags")}
</Typography> </Typography>
{controller.selectedTags.map((tagId) => { {c.selectedTags.map((tagId) => {
const tag = controller.tags?.find((t) => t.id === tagId); const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null; if (!tag) return null;
return ( return (
<ColoredLabel <ColoredLabel
@@ -123,20 +123,20 @@ export const MonitorListPage = ({
)} )}
{bulkActions && !isLoading && ( {bulkActions && !isLoading && (
<BulkActionsBar <BulkActionsBar
selectedCount={controller.selectedRows.length} selectedCount={c.selectedRows.length}
onCancel={controller.handleCancelSelection} onCancel={c.handleCancelSelection}
> >
<Button <Button
size="small" size="small"
startIcon={<Play size={16} />} startIcon={<Play size={16} />}
onClick={controller.handleBulkResume} onClick={c.handleBulkResume}
> >
{t("common.buttons.resume")} {t("common.buttons.resume")}
</Button> </Button>
<Button <Button
size="small" size="small"
startIcon={<Pause size={16} />} startIcon={<Pause size={16} />}
onClick={controller.handleBulkPause} onClick={c.handleBulkPause}
> >
{t("common.buttons.pause")} {t("common.buttons.pause")}
</Button> </Button>
@@ -144,12 +144,12 @@ export const MonitorListPage = ({
)} )}
{children} {children}
<Dialog <Dialog
open={controller.isDialogOpen} open={c.isDialogOpen}
title={t("common.dialogs.delete.title")} title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")} content={t("common.dialogs.delete.description")}
onConfirm={controller.handleConfirmDelete} onConfirm={c.handleConfirmDelete}
onCancel={controller.handleCancelDelete} onCancel={c.handleCancelDelete}
loading={controller.isDeleting} loading={c.isDeleting}
/> />
</MonitorBasePageWithStates> </MonitorBasePageWithStates>
); );
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
domainExpiry, domainExpiry,
}: MonitorStatBoxesProps) => { }: MonitorStatBoxesProps) => {
const theme = useTheme(); const theme = useTheme();
const { t, i18n } = useTranslation(); const { t } = useTranslation();
if (!monitorStats || !monitor) { if (!monitorStats || !monitor) {
return null; return null;
} }
@@ -29,16 +29,13 @@ export const MonitorStatBoxes = ({
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0; const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0; const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure); const streakTime = formatDuration(timeSinceLastFailure);
const lastCheckTime = timeOfLastCheck
? formatDuration(Date.now() - timeOfLastCheck, { const lastCheckTime = formatDuration(timeSinceLastCheck);
long: true,
relative: true,
locale: i18n.resolvedLanguage,
})
: t("common.labels.na");
const isActive = const isActive =
monitor?.status === "up" || monitor?.status === "up" ||
monitor?.status === "paused" || monitor?.status === "paused" ||
@@ -1,58 +1,15 @@
import { BaseChart, BaseBox } from "@/Components/design-elements"; import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts"; import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
import Typography from "@mui/material/Typography";
// Types // Types
import type { DockerContainerStats } from "@/Types/Monitor"; import type { DockerContainerStats } from "@/Types/Monitor";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
// Hooks // Hooks
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { Fragment, useId } from "react"; import { Fragment, useId } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
// Utils // Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils"; import { createGradient } from "@/Components/monitors/charts/ChartUtils";
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
import type { DateRange } from "@/Types/Query";
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
dataKey: string;
range: string;
theme: Theme;
uiTimezone: string;
valueFormatter: (value: number) => string;
labelKey: string;
};
const DockerHistogramToolTip = ({
dataKey,
active,
payload,
range,
theme,
uiTimezone,
valueFormatter,
labelKey,
}: DockerHistogramToolTipProps) => {
const { t } = useTranslation();
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const ts = payload[0].payload?._id ?? null;
const dataPoint = payload[0].payload?.[dataKey] ?? null;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
</BaseBox>
);
};
interface HistogramDockerContainerProps { interface HistogramDockerContainerProps {
title: string; title: string;
rightTitle: string; rightTitle: string;
@@ -60,9 +17,6 @@ interface HistogramDockerContainerProps {
dataKey: string; dataKey: string;
strokeColor: string; strokeColor: string;
gradientStartColor: string; gradientStartColor: string;
range: DateRange;
formatter: (value: number) => string;
labelKey: string;
} }
export const HistogramDockerContainer = ({ export const HistogramDockerContainer = ({
@@ -72,14 +26,11 @@ export const HistogramDockerContainer = ({
dataKey, dataKey,
strokeColor, strokeColor,
gradientStartColor, gradientStartColor,
range,
formatter,
labelKey,
}: HistogramDockerContainerProps) => { }: HistogramDockerContainerProps) => {
const uniqueId = useId(); const uniqueId = useId();
const theme = useTheme(); const theme = useTheme();
const gradientId = `gradient-${uniqueId}`; const gradientId = `gradient-${uniqueId}`;
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return ( return (
<BaseChart <BaseChart
icon="null" icon="null"
@@ -105,19 +56,6 @@ export const HistogramDockerContainer = ({
endColor: "transparent", endColor: "transparent",
direction: "vertical", direction: "vertical",
})} })}
<Tooltip
content={(props) => (
<DockerHistogramToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
dataKey={dataKey}
valueFormatter={formatter}
labelKey={labelKey}
/>
)}
/>
<Area <Area
dataKey={dataKey} dataKey={dataKey}
type="monotone" type="monotone"
+6 -12
View File
@@ -20,27 +20,21 @@ import {
Tag, Tag,
Waypoints, Waypoints,
Container, Container,
// LayoutDashboard,
} from "lucide-react"; } from "lucide-react";
export const getMenu = (t: Function) => { export const getMenu = (t: Function) => {
return [ return [
{ {
group: t("components.sidebar.menu.groups.monitoring"), group: t("components.sidebar.menu.groups.monitoring"),
items: [ items: [
// {
// name: t("components.sidebar.menu.dashboard"),
// path: "dashboard",
// icon: <Icon icon={LayoutDashboard} />,
// },
{ {
name: t("components.sidebar.menu.uptime"), name: t("components.sidebar.menu.uptime"),
path: "uptime", path: "uptime",
icon: <Icon icon={Globe} />, icon: <Icon icon={Globe} />,
}, },
{ {
name: t("components.sidebar.menu.docker"), name: t("components.sidebar.menu.pagespeed"),
path: "docker", path: "pagespeed",
icon: <Icon icon={Container} />, icon: <Icon icon={Gauge} />,
}, },
{ {
name: t("components.sidebar.menu.infrastructure"), name: t("components.sidebar.menu.infrastructure"),
@@ -48,9 +42,9 @@ export const getMenu = (t: Function) => {
icon: <Icon icon={Link} />, icon: <Icon icon={Link} />,
}, },
{ {
name: t("components.sidebar.menu.pagespeed"), name: t("components.sidebar.menu.docker"),
path: "pagespeed", path: "docker",
icon: <Icon icon={Gauge} />, icon: <Icon icon={Container} />,
}, },
], ],
}, },
+3 -2
View File
@@ -25,10 +25,11 @@ export const NavItem = ({
const iconStroke = selected const iconStroke = selected
? theme.palette.sidebar.accent ? theme.palette.sidebar.accent
: theme.palette.text.secondary; : theme.palette.text.secondary;
const buttonBgColor = selected ? theme.palette.action.selected : "transparent"; const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected const buttonBgHoverColor = selected
? theme.palette.action.selectedHover ? theme.palette.action.selected
: theme.palette.action.rowHover; : theme.palette.action.hover;
const fontWeight = selected ? 600 : 400; const fontWeight = selected ? 600 : 400;
return ( return (
<Tooltip <Tooltip
+1 -1
View File
@@ -73,11 +73,11 @@ export const Sidebar = () => {
gap={theme.spacing(LAYOUT.SM)} gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`} borderRight={`1px solid ${theme.palette.divider}`}
width={width} width={width}
bgcolor={theme.palette.background.paper}
sx={{ sx={{
touchAction: "none", touchAction: "none",
transition: transition, transition: transition,
zIndex: 1000, zIndex: 1000,
backdropFilter: "blur(8px)",
}} }}
> >
<List <List
-35
View File
@@ -1,16 +1,8 @@
import {
dashboardCardKeys,
type DashboardCardKey,
type DashboardSortOrder,
} from "@/Types/Dashboard";
import type { SortOrder } from "@/Types/Query";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line"; type ChartType = "histogram" | "line";
type TableName = type TableName =
| "dashboard"
| "monitors" | "monitors"
| "team" | "team"
| "maintenance" | "maintenance"
@@ -21,8 +13,6 @@ type TableName =
interface TableState { interface TableState {
rowsPerPage: number; rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
} }
interface SidebarState { interface SidebarState {
@@ -30,7 +20,6 @@ interface SidebarState {
} }
interface UIState { interface UIState {
dashboard: TableState;
monitors: TableState; monitors: TableState;
pagespeed: TableState; pagespeed: TableState;
team: TableState; team: TableState;
@@ -46,8 +35,6 @@ interface UIState {
language: string; language: string;
starPromptOpen: boolean; starPromptOpen: boolean;
chartType: ChartType; chartType: ChartType;
dashboardVisibleCards: DashboardCardKey[];
dashboardUptimeSortOrder: DashboardSortOrder;
} }
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)") const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -56,9 +43,6 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
: "light"; : "light";
const initialState: UIState = { const initialState: UIState = {
dashboard: {
rowsPerPage: 100,
},
monitors: { monitors: {
rowsPerPage: 10, rowsPerPage: 10,
}, },
@@ -90,8 +74,6 @@ const initialState: UIState = {
language: "en", language: "en",
starPromptOpen: true, starPromptOpen: true,
chartType: "histogram", chartType: "histogram",
dashboardVisibleCards: [...dashboardCardKeys],
dashboardUptimeSortOrder: "ascending",
}; };
const uiSlice = createSlice({ const uiSlice = createSlice({
@@ -108,14 +90,6 @@ const uiSlice = createSlice({
const { table, value } = action.payload; const { table, value } = action.payload;
state[table].rowsPerPage = value; state[table].rowsPerPage = value;
}, },
setTableSort: (
state,
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
) => {
const { table, field, order } = action.payload;
state[table].sortField = field;
state[table].sortOrder = order;
},
toggleSidebar: (state) => { toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed; state.sidebar.collapsed = !state.sidebar.collapsed;
}, },
@@ -143,12 +117,6 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => { setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload; state.chartType = action.payload;
}, },
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
state.dashboardVisibleCards = action.payload;
},
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
state.dashboardUptimeSortOrder = action.payload;
},
}, },
}); });
@@ -156,7 +124,6 @@ export type { UIState, ThemeMode, ChartType, TableName };
export default uiSlice.reducer; export default uiSlice.reducer;
export const { export const {
setRowsPerPage, setRowsPerPage,
setTableSort,
toggleSidebar, toggleSidebar,
setCollapsed, setCollapsed,
setMode, setMode,
@@ -166,6 +133,4 @@ export const {
setLanguage, setLanguage,
setStarPromptOpen, setStarPromptOpen,
setChartType, setChartType,
setDashboardVisibleCards,
setDashboardUptimeSortOrder,
} = uiSlice.actions; } = uiSlice.actions;
+1 -1
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useToast } from "@/Hooks/UseToast"; import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
export interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
msg: string; msg: string;
data: T; data: T;
+20 -23
View File
@@ -1,30 +1,27 @@
import { toast, type ToastOptions } from "react-toastify"; import { toast, type ToastOptions } from "react-toastify";
import { useMemo } from "react";
export const useToast = () => { export const useToast = () => {
return useMemo(() => { const showToast = (message: string, options?: ToastOptions) => {
const showToast = (message: string, options?: ToastOptions) => { toast.dismiss();
toast.dismiss(); const baseStyle: React.CSSProperties = {
const baseStyle: React.CSSProperties = { whiteSpace: "nowrap",
whiteSpace: "nowrap", overflow: "hidden",
overflow: "hidden", textOverflow: "ellipsis",
textOverflow: "ellipsis",
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
}; };
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
};
const toastSuccess = (msg: string, opts?: ToastOptions) => const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" }); showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) => const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" }); showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) => const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" }); showToast(msg, { ...opts, type: "info" });
const toastWarning = (msg: string, opts?: ToastOptions) => const toastWarning = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "warning" }); showToast(msg, { ...opts, type: "warning" });
return { showToast, toastSuccess, toastError, toastInfo, toastWarning }; return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
}, []);
}; };
-10
View File
@@ -77,16 +77,6 @@ export const getMonitorDefaults = (
...base, ...base,
type: "docker", type: "docker",
url: data?.url || "", url: data?.url || "",
secret: data?.secret || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
// The key is write-only; the API never returns it.
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
}; };
break; break;
case "game": case "game":
+8 -13
View File
@@ -1,4 +1,4 @@
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice"; import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi"; import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions"; import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce"; import useDebounce from "@/Hooks/useDebounce";
@@ -10,7 +10,6 @@ import {
type MonitorType, type MonitorType,
} from "@/Types/Monitor"; } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
@@ -25,12 +24,10 @@ interface MonitorListConfig {
export const useMonitorListController = (config: MonitorListConfig) => { export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const tableState = useSelector( const rowsPerPage = useSelector(
(state: RootState) => state.ui?.[config.rowsPerPageTable] (state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
); );
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
const sortOrder = tableState?.sortOrder ?? "asc";
// Filter states // Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]); const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
@@ -39,6 +36,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null); const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300); const debouncedSearch = useDebounce<string>(search, 300);
@@ -136,11 +135,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
setPage(0); setPage(0);
}; };
const setSort = (field: string, order: SortOrder) => {
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters // Check for active filters
const hasActiveFilters = Boolean( const hasActiveFilters = Boolean(
selectedTypes.length > 0 || selectedTypes.length > 0 ||
@@ -195,8 +189,9 @@ export const useMonitorListController = (config: MonitorListConfig) => {
rowsPerPage, rowsPerPage,
handleSetRowsPerPage, handleSetRowsPerPage,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSort, setSortOrder,
// Delete // Delete
selectedMonitor, selectedMonitor,
@@ -59,12 +59,7 @@ export const HeaderTeamControls = ({
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
startIcon={ startIcon={<Icon icon={Mail} />}
<Icon
icon={Mail}
size={16}
/>
}
onClick={onInviteClick} onClick={onInviteClick}
> >
{t("common.buttons.inviteMember")} {t("common.buttons.inviteMember")}
@@ -74,12 +69,7 @@ export const HeaderTeamControls = ({
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
startIcon={ startIcon={<Icon icon={UserPlus} />}
<Icon
icon={UserPlus}
size={16}
/>
}
onClick={onAddMemberClick} onClick={onAddMemberClick}
> >
{t("common.buttons.addMember")} {t("common.buttons.addMember")}
+27 -188
View File
@@ -38,7 +38,6 @@ import {
PageSpeedStrategies, PageSpeedStrategies,
DnsRecordTypes, DnsRecordTypes,
MonitorIntervalOptions, MonitorIntervalOptions,
MIN_MONITOR_INTERVAL_MS,
DefaultMonitorMatchMethod, DefaultMonitorMatchMethod,
MonitorMatchMethods, MonitorMatchMethods,
GeoCheckIntervalOptions, GeoCheckIntervalOptions,
@@ -51,8 +50,6 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import { import {
stepFieldsFor, stepFieldsFor,
monitorStepCount, monitorStepCount,
isCaptureDockerUrl,
isDockerTlsUrl,
type MonitorFormData, type MonitorFormData,
} from "@/Validation/monitor"; } from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -60,62 +57,15 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormAutocompleteField,
type AutocompleteOption,
} from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy"; import type { ProxyResponse } from "@/Types/Proxy";
import { formatDuration } from "@/Utils/TimeUtils";
const httpMethodOptions = HttpMethods.map((method) => ({ const httpMethodOptions = HttpMethods.map((method) => ({
value: method, value: method,
label: method, label: method,
})); }));
const PEM_FIELD_ROWS = 6;
// Hides the pasted key while the field is not focused. Ignored by browsers
// without -webkit-text-security, which then show the key as plain text.
const maskedInputSx = {
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
};
interface IntervalOption extends AutocompleteOption {
id: number;
}
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
const toIntervalOption = (
intervalMs: number,
locale: string | undefined
): IntervalOption => ({
id: intervalMs,
name: formatDuration(intervalMs, { long: true, locale }),
});
const filterIntervalOptions = (
options: IntervalOption[],
inputValue: string,
locale: string | undefined
): IntervalOption[] => {
const input = inputValue.trim();
if (input === "" || options.some((option) => option.name === input)) {
return options;
}
const amount = Number(input);
if (!Number.isFinite(amount) || amount <= 0) {
return [];
}
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
.filter(Number.isSafeInteger)
.map((ms) => toIntervalOption(ms, locale));
};
interface GeneralSettingsConfig { interface GeneralSettingsConfig {
urlLabel: string; urlLabel: string;
urlPlaceholder: string; urlPlaceholder: string;
@@ -168,8 +118,8 @@ const getGeneralSettingsConfig = (
showDnsRecordType: false, showDnsRecordType: false,
}, },
docker: { docker: {
urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"), urlLabel: t("pages.createMonitor.form.general.option.container.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"), urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true, showUrl: true,
showProxy: false, showProxy: false,
@@ -293,7 +243,7 @@ const getGeneralSettingsConfig = (
const CreateMonitorPage = () => { const CreateMonitorPage = () => {
const theme = useTheme(); const theme = useTheme();
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const { monitorId } = useParams(); const { monitorId } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -305,7 +255,6 @@ const CreateMonitorPage = () => {
const firstSegment = pathSegments[0]; const firstSegment = pathSegments[0];
if (firstSegment === "pagespeed") return "pagespeed"; if (firstSegment === "pagespeed") return "pagespeed";
if (firstSegment === "infrastructure") return "hardware"; if (firstSegment === "infrastructure") return "hardware";
if (firstSegment === "docker") return "docker";
return "uptime"; return "uptime";
}, [location.pathname]); }, [location.pathname]);
@@ -315,9 +264,7 @@ const CreateMonitorPage = () => {
? "pagespeed" ? "pagespeed"
: pageType === "hardware" : pageType === "hardware"
? "hardware" ? "hardware"
: pageType === "docker" : "http";
? "docker"
: "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>( const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
isEditMode ? `/monitors/${monitorId}` : null isEditMode ? `/monitors/${monitorId}` : null
@@ -350,13 +297,10 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step; const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType; const watchedType = watch("type") as MonitorType;
const watchedUrl = watch("url") as string;
const isCaptureDocker = watchedType === "docker" && isCaptureDockerUrl(watchedUrl);
const watchedMethod = watch("method") as HttpMethod | undefined; const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
const watchedInterval = watch("interval");
// Steps without an advanced section drop it, so the last step is the form's. // Steps without an advanced section drop it, so the last step is the form's.
const totalSteps = monitorStepCount(watchedType); const totalSteps = monitorStepCount(watchedType);
@@ -382,20 +326,11 @@ const CreateMonitorPage = () => {
} }
}, [watchedType, showTypeSelector, reset, clearErrors]); }, [watchedType, showTypeSelector, reset, clearErrors]);
// The logs switch is hidden for Capture hosts, so a value left over from a socket host must not be submitted.
useEffect(() => {
if (isCaptureDocker) {
setValue("dockerLogsEnabled", false);
}
}, [isCaptureDocker, setValue]);
const generalSettingsConfig = useMemo( const generalSettingsConfig = useMemo(
() => getGeneralSettingsConfig(watchedType, t), () => getGeneralSettingsConfig(watchedType, t),
[watchedType, t] [watchedType, t]
); );
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>(); const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>(); const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating; const isSubmitting = isCreating || isUpdating;
@@ -438,8 +373,6 @@ const CreateMonitorPage = () => {
navigate("/pagespeed"); navigate("/pagespeed");
} else if (pageType === "hardware") { } else if (pageType === "hardware") {
navigate("/infrastructure"); navigate("/infrastructure");
} else if (pageType === "docker") {
navigate("/docker");
} else { } else {
navigate("/uptime"); navigate("/uptime");
} }
@@ -521,18 +454,16 @@ const CreateMonitorPage = () => {
[t] [t]
); );
const intervalOptions = useMemo(() => { const intervalOptions = useMemo(
const options = MonitorIntervalOptions.map(({ value }) => () =>
toIntervalOption(value, i18n.resolvedLanguage) MonitorIntervalOptions.map((option) => ({
); value: option.value,
if ( label: t(
typeof watchedInterval === "number" && `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
!options.some((option) => option.id === watchedInterval) ),
) { })),
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage)); [t]
} );
return options;
}, [watchedInterval, i18n.resolvedLanguage]);
const matchMethodOptions = useMemo( const matchMethodOptions = useMemo(
() => () =>
@@ -617,6 +548,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(0) && ( {showStep(0) && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.general.title")} title={t("pages.createMonitor.form.general.title")}
@@ -717,8 +649,8 @@ const CreateMonitorPage = () => {
/> />
)} )}
{/* Capture API secret - used by hardware and Capture-backed Docker monitors */} {/* Secret field - only for hardware type */}
{(generalSettingsConfig.showSecret || isCaptureDocker) && ( {generalSettingsConfig.showSecret && (
<FormTextField <FormTextField
name="secret" name="secret"
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")} fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
@@ -727,12 +659,6 @@ const CreateMonitorPage = () => {
)} )}
/> />
)} )}
{isCaptureDocker && (
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.ignoreTls.option.tls.label")}
/>
)}
{generalSettingsConfig.showDnsServer && ( {generalSettingsConfig.showDnsServer && (
<FormTextField <FormTextField
name="dnsServer" name="dnsServer"
@@ -757,83 +683,23 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
subtitle={t("pages.createMonitor.form.dockerTls.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="dockerTlsCa"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsCert"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsKey"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.key.placeholder"
)}
helperText={
existingMonitor?.dockerTlsKeySet
? t("pages.createMonitor.form.dockerTls.option.key.stored")
: undefined
}
autoComplete="off"
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
/>
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
</Typography>
</Stack>
}
/>
)}
{showStep(1) && ( {showStep(1) && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.frequency.title")} title={t("pages.createMonitor.form.frequency.title")}
subtitle={t("pages.createMonitor.form.frequency.description")} subtitle={t("pages.createMonitor.form.frequency.description")}
rightContent={ rightContent={
<FormAutocompleteField <FormSelectField
name="interval" name="interval"
fieldLabel={t( fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label" "pages.createMonitor.form.frequency.option.frequency.label"
)} )}
options={intervalOptions} options={intervalOptions}
filterOptions={(options, { inputValue }) =>
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
}
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
disableClearable
/> />
} }
/> />
)} )}
{/* Alert Thresholds - only for hardware type */} {/* Alert Thresholds - only for hardware type */}
{watchedType === "hardware" && showStep(1) && ( {watchedType === "hardware" && showStep(1) && (
<ConfigBox <ConfigBox
@@ -889,6 +755,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(1) && ( {showStep(1) && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.incidents.title")} title={t("pages.createMonitor.form.incidents.title")}
@@ -915,6 +782,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(1) && ( {showStep(1) && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.notifications.title")} title={t("pages.createMonitor.form.notifications.title")}
@@ -927,6 +795,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(1) && ( {showStep(1) && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.tags.title")} title={t("pages.createMonitor.form.tags.title")}
@@ -953,40 +822,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
<ConfigBox
title={t("pages.createMonitor.form.dockerLogs.title")}
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
rightContent={
<FormSwitchField
name="dockerLogsEnabled"
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
/>
}
/>
)}
{showStep(1) && watchedType === "docker" && (
<ConfigBox
title={t("pages.createMonitor.form.dockerAlerts.title")}
subtitle={t("pages.createMonitor.form.dockerAlerts.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSwitchField
name="dockerAlertOnStopped"
label={t(
"pages.createMonitor.form.dockerAlerts.option.onStopped.label"
)}
/>
<FormSwitchField
name="dockerAlertOnUnhealthy"
label={t(
"pages.createMonitor.form.dockerAlerts.option.onUnhealthy.label"
)}
/>
</Stack>
}
/>
)}
{showStep(2) && {showStep(2) &&
(watchedType === "http" || (watchedType === "http" ||
watchedType === "grpc" || watchedType === "grpc" ||
@@ -1002,6 +838,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(2) && watchedType === "http" && ( {showStep(2) && watchedType === "http" && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.advanced.title")} title={t("pages.createMonitor.form.advanced.title")}
@@ -1101,6 +938,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
{showStep(2) && supportsGeoCheck(watchedType) && ( {showStep(2) && supportsGeoCheck(watchedType) && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")} title={t("pages.createMonitor.form.geoChecks.title")}
@@ -1133,6 +971,7 @@ const CreateMonitorPage = () => {
} }
/> />
)} )}
<Stack <Stack
direction="row" direction="row"
justifyContent={!isEditMode ? "space-between" : "flex-end"} justifyContent={!isEditMode ? "space-between" : "flex-end"}
@@ -1,54 +0,0 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface DashboardCardProps {
title: string;
topRight?: React.ReactNode;
children: React.ReactNode;
}
export const DashboardCard = ({ title, topRight, children }: DashboardCardProps) => {
const theme = useTheme();
return (
<Box
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={`${theme.shape.borderRadius}px`}
padding={theme.spacing(LAYOUT.MD)}
display="flex"
flexDirection="column"
gap={theme.spacing(LAYOUT.SM)}
height="100%"
boxSizing="border-box"
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Typography
fontSize={typographyLevels.m}
fontWeight={500}
color={theme.palette.text.primary}
>
{title}
</Typography>
{topRight && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{topRight}
</Typography>
)}
</Stack>
{children}
</Box>
);
};
@@ -1,89 +0,0 @@
import { useMemo, useState } from "react";
import Stack from "@mui/material/Stack";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { Checkbox, Dialog } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
interface EditCardsModalProps {
open: boolean;
visibleCards: Set<DashboardCardKey>;
onClose: () => void;
onSave: (visibleCards: Set<DashboardCardKey>) => void;
}
export const EditCardsModal = ({
open,
visibleCards,
onClose,
onSave,
}: EditCardsModalProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
new Set(visibleCards)
);
const handleClose = () => {
setCardsSelection(new Set(visibleCards));
onClose();
};
const handleToggle = (key: DashboardCardKey) => {
setCardsSelection((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const sortedCardKeys = useMemo(
() =>
[...dashboardCardKeys].sort((a, b) =>
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
),
[t]
);
return (
<Dialog
open={open}
title={t("pages.dashboard.editCardsModal.title")}
content={t("pages.dashboard.editCardsModal.description")}
onCancel={handleClose}
onConfirm={() => onSave(cardsSelection)}
confirmText={t("pages.dashboard.editCardsModal.done")}
maxWidth="xs"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<Typography
variant="eyebrow"
color={theme.palette.text.disabled}
>
{t("pages.dashboard.editCardsModal.groups.uptime")}
</Typography>
{sortedCardKeys.map((key: DashboardCardKey) => (
<FormControlLabel
key={key}
label={t(`pages.dashboard.cards.${key}`)}
control={
<Checkbox
checked={cardsSelection.has(key)}
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
onChange={() => handleToggle(key)}
/>
}
/>
))}
</Stack>
</Dialog>
);
};
@@ -1,28 +0,0 @@
import { LAYOUT } from "@/Utils/Theme/constants";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
interface ProgressBarProps {
fillRatio: number;
}
export const ProgressBar = ({ fillRatio }: ProgressBarProps) => {
const theme = useTheme();
const clamped = Math.min(1, Math.max(0, fillRatio));
return (
<Box
height={theme.spacing(LAYOUT.XS)}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.disabledBackground}
overflow="hidden"
>
<Box
height="100%"
width={`${clamped * 100}%`}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.primary.main}
/>
</Box>
);
};
@@ -1,42 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { SPACING } from "@/Utils/Theme/constants";
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
interface BarRowProps {
label: string;
value: string;
fillRatio: number;
valueColor?: string;
}
export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(SPACING.LG)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={valueColor ?? theme.palette.primary.main}
fontWeight={500}
>
{value}
</Typography>
</Stack>
<ProgressBar fillRatio={fillRatio} />
</Stack>
);
};
@@ -1,58 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { Dot } from "@/Components/design-elements";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { Monitor } from "@/Types/Monitor";
interface CurrentlyDownCardProps {
monitors: Monitor[];
}
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
return (
<DashboardCard
title={t("pages.dashboard.cards.currentlyDown")}
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
>
{downMonitors.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.currentlyDown.empty")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{downMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
>
<Dot color={theme.palette.error.main} />
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{monitor.name}
</Typography>
</Stack>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -1,58 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import type { MonitorsSummary } from "@/Types/Monitor";
interface MonitorStatusCardProps {
summary: MonitorsSummary | null;
}
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const up = summary?.upMonitors ?? 0;
const total = summary?.totalMonitors ?? 0;
const ratio = total > 0 ? up / total : 0;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
{total === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
)}
</DashboardCard>
);
};
@@ -1,46 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import type { MonitorTypeCount } from "@/Types/Monitor";
import { getMonitorTypeLabel } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface MonitorsByTypeCardProps {
monitorsByType: MonitorTypeCount[];
}
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
const { t } = useTranslation();
const theme = useTheme();
const maxCount = monitorsByType[0]?.count ?? 1;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
{monitorsByType.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -1,90 +0,0 @@
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Icon } from "@/Components/design-elements";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
import type { Monitor } from "@/Types/Monitor";
import type { AppDispatch, RootState } from "@/store";
const TOP_N = 5;
interface UptimeCardProps {
monitors: Monitor[];
}
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
const direction = sortOrder === "ascending" ? 1 : -1;
const ranked = monitors
.filter((monitor) => monitor.status !== "paused")
.filter(
(monitor): monitor is Monitor & { uptimePercentage: number } =>
typeof monitor.uptimePercentage === "number"
)
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
.slice(0, TOP_N);
return (
<DashboardCard
title={t("pages.dashboard.cards.uptime")}
topRight={
<IconButton
size="small"
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
onClick={() =>
dispatch(
setDashboardUptimeSortOrder(
sortOrder === "ascending" ? "descending" : "ascending"
)
)
}
>
<Icon
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
size={16}
color={theme.palette.text.secondary}
/>
</IconButton>
}
>
{ranked.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.map((monitor) => {
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<BarRow
key={monitor.id}
label={monitor.name}
value={formatPercentage(monitor.uptimePercentage)}
fillRatio={monitor.uptimePercentage}
valueColor={color}
/>
);
})}
</Stack>
)}
</DashboardCard>
);
};
-100
View File
@@ -1,100 +0,0 @@
import { Fragment, useMemo, useState, type ReactNode } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { BasePage } from "@/Components/design-elements/BasePage";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
import type { RootState, AppDispatch } from "@/store";
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const [editOpen, setEditOpen] = useState(false);
const visibleCardsList = useSelector(
(state: RootState) => state.ui.dashboardVisibleCards
);
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
const { monitors, summary } = useMonitorListController({
types: [...MonitorTypes],
checksLimit: 1,
refreshInterval: 30000,
rowsPerPageTable: "dashboard",
rowsPerPageDefault: 100,
});
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
const countMap = new Map<MonitorTypeCount["type"], number>();
(monitors ?? []).forEach((m) =>
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
);
return [...countMap.entries()]
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
}, [monitors]);
const cardComponents: Record<DashboardCardKey, ReactNode> = {
monitorStatus: <MonitorStatusCard summary={summary} />,
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
uptime: <UptimeCard monitors={monitors ?? []} />,
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
};
return (
<BasePage headerKey="dashboard">
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="outlined"
size="small"
onClick={() => setEditOpen(true)}
>
{t("pages.dashboard.editCards")}
</Button>
</Stack>
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
{dashboardCardKeys
.filter((key) => visibleCards.has(key))
.map((key) => (
<Fragment key={key}>{cardComponents[key]}</Fragment>
))}
</Box>
</Stack>
<EditCardsModal
open={editOpen}
visibleCards={visibleCards}
onClose={() => setEditOpen(false)}
onSave={(next) => {
dispatch(setDashboardVisibleCards([...next]));
setEditOpen(false);
}}
/>
</BasePage>
);
};
export default Dashboard;
Whitespace-only changes.
@@ -1,69 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Dot } from "@/Components/design-elements";
// Hooks
import { useEffect, useState } from "react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
// Utils
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
interface HeaderLogsProps {
lines: number;
loading: boolean;
lastUpdatedAt: number | null;
}
export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (lastUpdatedAt === null) return;
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
return () => window.clearInterval(id);
}, [lastUpdatedAt]);
const renderStatus = () => {
if (loading) return t("pages.docker.container.logs.loading");
if (lastUpdatedAt === null) return null;
const ageMs = Math.max(0, now - lastUpdatedAt);
return ageMs < MS_PER_MINUTE
? t("pages.docker.container.logs.updatedSecondsAgo", {
count: Math.floor(ageMs / MS_PER_SECOND),
})
: t("pages.docker.container.logs.updatedMinutesAgo", {
count: Math.floor(ageMs / MS_PER_MINUTE),
});
};
return (
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.MD}
>
<Typography>
{t("pages.docker.container.logs.recentLines", { count: lines })}
</Typography>
<Stack
direction="row"
alignItems="center"
gap={SPACING.SM}
>
{lastUpdatedAt !== null && !loading && (
<Dot
color={theme.palette.success.main}
size="md"
/>
)}
<Typography color={theme.palette.text.secondary}>{renderStatus()}</Typography>
</Stack>
</Stack>
);
};
@@ -1,78 +0,0 @@
import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import { Fragment } from "react";
// Hooks
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
// Utils
import type { DockerLogRow } from "@/Utils/MonitorUtils";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/store";
export const RowLog = ({ row }: { row: DockerLogRow }) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { t } = useTranslation();
return (
<Fragment>
<Grid
container
spacing={{ xs: 0, md: LAYOUT.MD }}
py={{ xs: LAYOUT.LG, md: 0 }}
>
<Grid size={{ xs: 12, md: "auto" }}>
<Typography
fontFamily="monospace"
color={theme.palette.text.secondary}
>
{formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: "grow" }}>
<Typography
fontFamily={"monospace"}
whiteSpace={{ xs: "pre-wrap", md: "nowrap" }}
sx={{
overflowWrap: { xs: "anywhere", md: "normal" },
}}
>
<Box
component={"span"}
color={
row.stream === "stderr"
? theme.palette.error.main
: theme.palette.success.main
}
>
{row.stream}
</Box>
: {row.text}
</Typography>
</Grid>
</Grid>
{row.gapBefore ? (
<Stack
width="100%"
direction="row"
alignItems={"center"}
py={SPACING.MD}
>
<Typography pr={SPACING.MD}>
{t("pages.docker.container.logs.skipped")}
</Typography>
<Divider sx={{ flex: 1 }} />
</Stack>
) : (
<Divider sx={{ display: { xs: "block", md: "none" } }} />
)}
</Fragment>
);
};
@@ -10,8 +10,6 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils // Utils
import { formatDuration } from "@/Utils/TimeUtils"; import { formatDuration } from "@/Utils/TimeUtils";
import { PLACEHOLDER } from "@/Utils/FormatUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({ export const DockerContainerStatusBoxes = ({
container, container,
@@ -24,10 +22,8 @@ export const DockerContainerStatusBoxes = ({
if (!container) return null; if (!container) return null;
const { state, restartCount, health } = container; const { state, restartCount, health } = container;
const runningFor = container.startedAt const startedAt = container.startedAt ?? "0";
? Date.now() - new Date(container.startedAt).getTime() const runningFor = Date.now() - new Date(startedAt).getTime();
: undefined;
const palette = getDockerPalette(state);
return ( return (
<Stack <Stack
direction="row" direction="row"
@@ -37,15 +33,14 @@ export const DockerContainerStatusBoxes = ({
<StatBox <StatBox
title={t("common.labels.state")} title={t("common.labels.state")}
subtitle={state} subtitle={state}
palette={palette}
/> />
<StatBox <StatBox
title={t("common.labels.uptime")} title={t("common.labels.uptime")}
subtitle={runningFor === undefined ? PLACEHOLDER : formatDuration(runningFor)} subtitle={formatDuration(runningFor)}
/> />
<StatBox <StatBox
title={t("common.labels.restarts")} title={t("common.labels.restarts")}
subtitle={restartCount === undefined ? PLACEHOLDER : String(restartCount)} subtitle={String(restartCount ?? 0)}
/> />
<StatBox <StatBox
title={t("common.labels.health")} title={t("common.labels.health")}
@@ -1,252 +1,3 @@
// Components export const TabLogs = () => {
import Stack from "@mui/material/Stack"; return "This is the logs tab";
import Typography from "@mui/material/Typography";
import { BaseBox, EmptyState } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
// Types
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
import type { DockerLog } from "@/Types/Check";
import type { ApiResponse } from "@/Hooks/UseApi";
// Hooks
import {
useState,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useCallback,
} from "react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// Utils
import { get } from "@/Utils/ApiClient";
import { LAYOUT } from "@/Utils/Theme/constants";
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
interface TabLogsProps {
monitorId?: string;
containerName?: string;
enabled: boolean;
}
const SCROLL_PIN_THRESHOLD_PX = 8;
const TAIL_POLL_MS = 10_000;
const isScrolledToBottom = (el: HTMLElement) =>
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
const res = await get<ApiResponse<DockerContainerLogsResponse>>(url, { params });
return res.data.data;
};
export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
const isAdmin = useIsAdmin();
// logs is newest-first, matching the API and what flattenDockerLogs expects.
const [logs, setLogs] = useState<DockerLog[]>([]);
const [olderExhausted, setOlderExhausted] = useState(false);
const [loadingInitial, setLoadingInitial] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const logsUrl =
isAdmin && enabled && monitorId && containerName
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
: null;
const newestCursor = logs[0]?.checkedAt;
const oldestCursor = logs[logs.length - 1]?.checkedAt;
// Initial page. Resets everything when the container changes.
useEffect(() => {
setLogs([]);
setOlderExhausted(false);
if (!logsUrl) return;
let cancelled = false;
setLoadingInitial(true);
fetchLogs(logsUrl, {})
.then((page) => {
if (cancelled) return;
setLogs(page.logs);
setOlderExhausted(page.nextCursor === null);
setLastUpdatedAt(Date.now());
})
.catch(() => {
if (cancelled) return;
toastError(t("pages.docker.container.logs.loadError"));
})
.finally(() => {
if (!cancelled) setLoadingInitial(false);
});
return () => {
cancelled = true;
};
}, [logsUrl, t, toastError]);
const newestCursorRef = useRef(newestCursor);
newestCursorRef.current = newestCursor;
useEffect(() => {
if (!logsUrl) return;
let inFlight = false;
const tick = async () => {
if (inFlight || document.hidden) return;
inFlight = true;
try {
const cursor = newestCursorRef.current;
const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
setLastUpdatedAt(Date.now());
} catch (err: unknown) {
console.error(err);
} finally {
inFlight = false;
}
};
const id = window.setInterval(tick, TAIL_POLL_MS);
document.addEventListener("visibilitychange", tick);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, [logsUrl]);
const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
const logBoxRef = useRef<HTMLDivElement>(null);
const pinnedToBottom = useRef(true);
const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
// Always pin to bottom when container/monitor changes
useLayoutEffect(() => {
pinnedToBottom.current = true;
}, [monitorId, containerName]);
// Rows changed: restore position after a prepend, otherwise honour the pin
useLayoutEffect(() => {
const el = logBoxRef.current;
if (!el) return;
const anchor = prependAnchor.current;
if (anchor) {
prependAnchor.current = null;
el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
return;
}
if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
}, [rows]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
}, []);
const loadOlder = useCallback(async () => {
if (!logsUrl || !oldestCursor || loadingOlder) return;
setLoadingOlder(true);
try {
const page = await fetchLogs(logsUrl, { before: oldestCursor });
const el = logBoxRef.current;
if (el)
prependAnchor.current = {
scrollHeight: el.scrollHeight,
scrollTop: el.scrollTop,
};
setLogs((prev) => [...prev, ...page.logs]);
setOlderExhausted(page.nextCursor === null);
} catch {
toastError(t("pages.docker.container.logs.loadOlderError"));
} finally {
setLoadingOlder(false);
}
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
if (!isAdmin) {
return (
<EmptyState
title={t("pages.docker.container.logs.forbidden.title")}
description={t("pages.docker.container.logs.forbidden.description")}
/>
);
}
if (!enabled) {
return (
<EmptyState
title={t("pages.docker.container.logs.disabled.title")}
description={t("pages.docker.container.logs.disabled.description")}
actionText={t("pages.docker.container.logs.disabled.action")}
actionTo={`/docker/configure/${monitorId}`}
/>
);
}
if (lastUpdatedAt !== null && rows.length === 0) {
return (
<EmptyState
title={t("pages.docker.container.logs.empty.title")}
description={t("pages.docker.container.logs.empty.description")}
/>
);
}
return (
<Stack gap={LAYOUT.MD}>
<HeaderLogs
lines={rows.length}
loading={loadingInitial}
lastUpdatedAt={lastUpdatedAt}
/>
<BaseBox
ref={logBoxRef}
onScroll={handleScroll}
minHeight={240}
maxHeight="50vh"
overflow="auto"
padding={LAYOUT.MD}
sx={{ overflowAnchor: "none" }}
>
{rows.length > 0 && (
<Stack
direction="row"
justifyContent="center"
pb={LAYOUT.MD}
>
{olderExhausted ? (
<Typography color={theme.palette.text.secondary}>
{t("pages.docker.container.logs.noOlder")}
</Typography>
) : (
<Button
variant="contained"
disabled={loadingOlder}
onClick={loadOlder}
>
{loadingOlder
? t("pages.docker.container.logs.loadingOlder")
: t("pages.docker.container.logs.loadOlder")}
</Button>
)}
</Stack>
)}
{rows.map((row) => {
return (
<RowLog
key={row.key}
row={row}
/>
);
})}
</BaseBox>
</Stack>
);
}; };
@@ -13,11 +13,10 @@ import type { Theme } from "@mui/material";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor"; import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils"; import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements"; import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils"; import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
import type { DateRange } from "@/Types/Query";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{ {
@@ -25,29 +24,20 @@ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats
key: "avgCpuPct", key: "avgCpuPct",
color: theme.palette.success.main, color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0), rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
formatter: formatPercentage,
labelKey: "common.charts.histogram.avgCpuUsage",
}, },
{ {
title: t("common.charts.labels.memoryUsage"), title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes", key: "avgMemoryUsedBytes",
color: theme.palette.primary.main, color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0), rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
formatter: prettyBytes,
labelKey: "common.charts.histogram.avgMemoryUsage",
}, },
]; ];
export const TabOverview = ({ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
stats,
range,
}: {
stats: DockerContainerStats;
range: DateRange;
}) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {}; const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []); const dedupedPorts = dedupeDockerPorts(ports ?? []);
return ( return (
@@ -68,9 +58,6 @@ export const TabOverview = ({
dataKey={config.key} dataKey={config.key}
strokeColor={config.color} strokeColor={config.color}
gradientStartColor={config.color} gradientStartColor={config.color}
range={range}
formatter={config.formatter}
labelKey={config.labelKey}
/> />
</Grid> </Grid>
); );
@@ -88,17 +75,11 @@ export const TabOverview = ({
Ports Ports
</Typography> </Typography>
<Stack> <Stack>
{dedupedPorts.length === 0 && <Typography>{PLACEHOLDER}</Typography>} {dedupedPorts?.map((port) => {
{dedupedPorts.map((port) => {
const containerPort = `${port.privatePort}/${port.protocol}`;
const label =
port.publicPort === undefined
? containerPort
: `${port.hostIp ?? "0.0.0.0"}:${port.publicPort} -> ${containerPort}`;
return ( return (
<Typography key={`${port.hostIp}/${port.publicPort}/${containerPort}`}> <Typography
{label} key={`${port.publicPort}/${port.privatePort}`}
</Typography> >{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
); );
})} })}
</Stack> </Stack>
@@ -117,7 +98,6 @@ export const TabOverview = ({
Volumes Volumes
</Typography> </Typography>
<Stack> <Stack>
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
{mounts?.map((mount) => { {mounts?.map((mount) => {
return ( return (
<Typography key={`${mount.source}/${mount.destination}`}> <Typography key={`${mount.source}/${mount.destination}`}>
@@ -69,19 +69,8 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
<Tab label={t("pages.docker.container.tabs.overview")} /> <Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} /> <Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs> </Tabs>
{activeTab === 0 && ( {activeTab === 0 && <TabOverview stats={stats} />}
<TabOverview {activeTab === 1 && <TabLogs />}
stats={stats}
range={dateRange}
/>
)}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
containerName={containerName}
enabled={monitor.dockerLogsEnabled ?? false}
/>
)}
</BasePage> </BasePage>
); );
}; };
@@ -12,7 +12,7 @@ import type { Header } from "@/Components/design-elements/Table";
import type { DockerContainerInfo } from "@/Types/Check"; import type { DockerContainerInfo } from "@/Types/Check";
// Utils // Utils
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils"; import { formatPercentage } from "@/Utils/FormatUtils";
interface DockerContainersTableProps { interface DockerContainersTableProps {
monitorId: string | undefined; monitorId: string | undefined;
@@ -91,7 +91,7 @@ export const DockerContainersTable = ({
id: "restarts", id: "restarts",
content: t("common.labels.restarts"), content: t("common.labels.restarts"),
render: (row) => { render: (row) => {
return <Typography>{row.restartCount ?? PLACEHOLDER}</Typography>; return <Typography>{row.restartCount}</Typography>;
}, },
}, },
]; ];
@@ -8,7 +8,6 @@ import {
ColoredLabel, ColoredLabel,
} from "@/Components/design-elements"; } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react"; import { ArrowUp, ArrowDown } from "lucide-react";
@@ -21,7 +20,6 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { DockerContainerSummary } from "@/Types/Check"; import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
@@ -36,8 +34,9 @@ interface DockerMonitorsTableProps {
refetch: () => void; refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void; setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string; sortField: string;
sortOrder: SortOrder; setSortField: (field: string) => void;
setSort: (field: string, order: SortOrder) => void; sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -53,8 +52,9 @@ export const DockerMonitorsTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSort, setSortOrder,
count, count,
page, page,
setPage, setPage,
@@ -105,8 +105,14 @@ export const DockerMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => { const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder); if (sortField === field) {
setSort(nextSort.field, nextSort.order); const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const getActions = (monitor: Monitor): ActionMenuItem[] => { const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -321,7 +327,11 @@ export const DockerMonitorsTable = ({
onRowClick={(row) => { onRowClick={(row) => {
navigate(`/docker/host/${row.id}`); navigate(`/docker/host/${row.id}`);
}} }}
isRowSelected={(row) => isRowSelected(row.id)} getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/> />
<Pagination <Pagination
component="div" component="div"
+2 -2
View File
@@ -19,7 +19,6 @@ const DockerMonitorsPage = () => {
controller={c} controller={c}
bulkActions bulkActions
showTypeFilter={false} showTypeFilter={false}
summaryProps={{ showBreached: true }}
> >
<DockerMonitorsTable <DockerMonitorsTable
monitors={c.monitors || []} monitors={c.monitors || []}
@@ -31,8 +30,9 @@ const DockerMonitorsPage = () => {
setPage={c.setPage} setPage={c.setPage}
rowsPerPage={c.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={c.sortField} sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder} sortOrder={c.sortOrder}
setSort={c.setSort} setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage} setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows} selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows} onSelectionChange={c.setSelectedRows}
@@ -30,7 +30,6 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
direction="row" direction="row"
alignItems="center" alignItems="center"
gap={theme.spacing(2)} gap={theme.spacing(2)}
sx={{ "& svg": { color: theme.palette.text.secondary } }}
> >
{icon} {icon}
<Typography variant="body1">{label}</Typography> <Typography variant="body1">{label}</Typography>
@@ -144,10 +143,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2), gap: theme.spacing(2),
}} }}
> >
<Icon <Icon icon={Globe} />
icon={Globe}
color={theme.palette.text.secondary}
/>
<Typography <Typography
variant="body1" variant="body1"
fontWeight={500} fontWeight={500}
@@ -9,7 +9,6 @@ import {
ColoredLabel, ColoredLabel,
} from "@/Components/design-elements"; } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react"; import { ArrowUp, ArrowDown } from "lucide-react";
@@ -22,7 +21,6 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps { interface InfraMonitorsTableProps {
@@ -31,8 +29,9 @@ interface InfraMonitorsTableProps {
refetch: () => void; refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void; setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string; sortField: string;
sortOrder: SortOrder; setSortField: (field: string) => void;
setSort: (field: string, order: SortOrder) => void; sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -48,8 +47,9 @@ export const InfraMonitorsTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSort, setSortOrder,
count, count,
page, page,
setPage, setPage,
@@ -100,8 +100,14 @@ export const InfraMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => { const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder); if (sortField === field) {
setSort(nextSort.field, nextSort.order); const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const getActions = (monitor: Monitor): ActionMenuItem[] => { const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -325,7 +331,11 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => { onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`); navigate(`/infrastructure/${row.id}`);
}} }}
isRowSelected={(row) => isRowSelected(row.id)} getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/> />
<Pagination <Pagination
component="div" component="div"
@@ -31,8 +31,9 @@ const InfrastructureMonitors = () => {
setPage={c.setPage} setPage={c.setPage}
rowsPerPage={c.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={c.sortField} sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder} sortOrder={c.sortOrder}
setSort={c.setSort} setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage} setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows} selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows} onSelectionChange={c.setSelectedRows}
@@ -1,7 +1,7 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { DetailGauge } from "@/Components/design-elements"; import { DetailGauge } from "@/Components/design-elements";
import { getPercentage, formatPercentageFromWhole, PLACEHOLDER } from "@/Utils/FormatUtils"; import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
@@ -11,6 +11,7 @@ interface StatGaugesProps {
diagnostics: Diagnostics | null; diagnostics: Diagnostics | null;
} }
const PLACEHOLDER = "—";
export const StatGauges = ({ diagnostics }: StatGaugesProps) => { export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
const theme = useTheme(); const theme = useTheme();
+1 -1
View File
@@ -6,12 +6,12 @@ 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"; import { formatDuration, formatMs } from "@/Utils/TimeUtils";
import { PLACEHOLDER } from "@/Utils/FormatUtils";
interface StatsProps { interface StatsProps {
diagnostics: Diagnostics | null; diagnostics: Diagnostics | null;
} }
const PLACEHOLDER = "—";
export const Stats = ({ diagnostics }: StatsProps) => { export const Stats = ({ diagnostics }: StatsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => { const LogsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(0); const [activeTab, setActiveTab] = useState<number>(1);
return ( return (
<BasePage headerKey="logs"> <BasePage headerKey="logs">
<Tabs <Tabs
+5 -6
View File
@@ -1,4 +1,4 @@
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react"; import NotFoundSvg from "@/assets/Images/sushi_404.svg";
import { Button } from "@/Components/inputs"; import { Button } from "@/Components/inputs";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
@@ -36,11 +36,10 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
alignItems="center" alignItems="center"
> >
<Box <Box
component={NotFoundSvg} component="img"
role="img" src={NotFoundSvg}
aria-label="404" alt="404"
width="100%" maxHeight={"25rem"}
maxHeight="25rem"
/> />
<Typography <Typography
component="h1" component="h1"
@@ -126,7 +126,6 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" && {watchedType !== "matrix" &&
watchedType !== "telegram" && watchedType !== "telegram" &&
watchedType !== "pushover" && watchedType !== "pushover" &&
watchedType !== "signalgrid" &&
watchedType !== "twilio" && watchedType !== "twilio" &&
watchedType !== "ntfy" && ( watchedType !== "ntfy" && (
<ConfigBox <ConfigBox
@@ -202,31 +201,6 @@ const NotificationsCreatePage = () => {
} }
/> />
)} )}
{watchedType === "signalgrid" && (
<ConfigBox
title={t("pages.notifications.form.signalgrid.title")}
subtitle={t("pages.notifications.form.signalgrid.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderClientKey"
)}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
placeholder={t(
"pages.notifications.form.signalgrid.placeholderChannel"
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && ( {watchedType === "twilio" && (
<ConfigBox <ConfigBox
title={t("pages.notifications.form.twilio.title")} title={t("pages.notifications.form.twilio.title")}
@@ -9,7 +9,6 @@ import {
ColoredLabel, ColoredLabel,
} from "@/Components/design-elements"; } from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import { HistogramPageSpeed } from "@/Components/monitors"; import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
@@ -23,7 +22,6 @@ import { usePatch } from "@/Hooks/UseApi";
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor"; import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { ActionMenuItem } from "@/Components/actions-menu"; import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({ export const PageSpeedMonitorsTable = ({
@@ -32,8 +30,9 @@ export const PageSpeedMonitorsTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSort, setSortOrder,
count, count,
page, page,
setPage, setPage,
@@ -45,8 +44,9 @@ export const PageSpeedMonitorsTable = ({
refetch: Function; refetch: Function;
setSelectedMonitor: Function; setSelectedMonitor: Function;
sortField: string; sortField: string;
sortOrder: SortOrder; setSortField: (field: string) => void;
setSort: (field: string, order: SortOrder) => void; sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -81,8 +81,14 @@ export const PageSpeedMonitorsTable = ({
const handleSort = (e: any, field: string) => { const handleSort = (e: any, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder); if (sortField === field) {
setSort(nextSort.field, nextSort.order); const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const getActions = (monitor: Monitor): ActionMenuItem[] => { const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -46,8 +46,9 @@ const PageSpeedMonitorsPage = () => {
setPage={c.setPage} setPage={c.setPage}
rowsPerPage={c.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={c.sortField} sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder} sortOrder={c.sortOrder}
setSort={c.setSort} setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage} setRowsPerPage={c.handleSetRowsPerPage}
/> />
</MonitorListPage> </MonitorListPage>
@@ -25,12 +25,7 @@ export const HeaderConfigStatusControls = ({
<Button <Button
variant="contained" variant="contained"
color="error" color="error"
startIcon={ startIcon={<Icon icon={Trash} />}
<Icon
icon={Trash}
size={16}
/>
}
onClick={onDelete} onClick={onDelete}
> >
{translate.t("common.buttons.delete")} {translate.t("common.buttons.delete")}
@@ -77,12 +77,7 @@ export const HeaderStatusPageControls = ({
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
startIcon={ startIcon={<Icon icon={Settings} />}
<Icon
icon={Settings}
size={16}
/>
}
onClick={() => navigate(`/status/configure/${statusPage.url}`)} onClick={() => navigate(`/status/configure/${statusPage.url}`)}
> >
{t("common.buttons.configure")} {t("common.buttons.configure")}
@@ -14,7 +14,6 @@ import {
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 { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal";
import { import {
ThemedInfrastructure, ThemedInfrastructure,
type GaugeFillLevel, type GaugeFillLevel,
@@ -117,8 +116,6 @@ export const BaseStatusPage = ({
[config, tokens, mode] [config, tokens, mode]
); );
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const [selectedMonitorId, setSelectedMonitorId] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions); const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
const logoSrc = statusPage.logo?.data const logoSrc = statusPage.logo?.data
@@ -214,7 +211,6 @@ export const BaseStatusPage = ({
> >
<Icon <Icon
icon={TriangleAlert} icon={TriangleAlert}
size={20}
color={theme.palette.warning.main} color={theme.palette.warning.main}
/> />
<Typography> <Typography>
@@ -309,10 +305,6 @@ export const BaseStatusPage = ({
cells={cells} cells={cells}
containerSx={styles.heatmap} containerSx={styles.heatmap}
cellSx={styles.heatmapCell} cellSx={styles.heatmapCell}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/> />
) : ( ) : (
<ThemedHistogram <ThemedHistogram
@@ -320,10 +312,6 @@ export const BaseStatusPage = ({
containerSx={styles.histogram} containerSx={styles.histogram}
barSx={styles.bar} barSx={styles.bar}
statsSx={styles.chartStats} statsSx={styles.chartStats}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/> />
))} ))}
</Box> </Box>
@@ -344,16 +332,6 @@ export const BaseStatusPage = ({
Checkmate Checkmate
</a> </a>
</Box> </Box>
<IncidentModal
url={statusPage.url}
monitorId={selectedMonitorId}
date={selectedDate}
timezone={bucketTimezone}
onClose={() => {
setSelectedMonitorId(null);
setSelectedDate(null);
}}
/>
</Box> </Box>
); );
}; };
@@ -25,7 +25,6 @@ export interface ChartCell {
responseTime: number; // 0 for empty cells responseTime: number; // 0 for empty cells
tooltip: React.ReactNode | null; tooltip: React.ReactNode | null;
ariaLabel: string; ariaLabel: string;
date: string | null;
} }
const DAY_DOWN_THRESHOLD = 0.5; const DAY_DOWN_THRESHOLD = 0.5;
@@ -62,7 +61,7 @@ export const classifyResponseTime = (
return "fast"; return "fast";
}; };
const emptyCell = (key: string, date: string | null = null): ChartCell => ({ const emptyCell = (key: string): ChartCell => ({
key, key,
barKind: "empty", barKind: "empty",
heatKind: "empty", heatKind: "empty",
@@ -71,7 +70,6 @@ const emptyCell = (key: string, date: string | null = null): ChartCell => ({
tooltip: null, tooltip: null,
ariaLabel: "", ariaLabel: "",
severity: 0, severity: 0,
date,
}); });
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -94,7 +92,6 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
tooltip: <ThemedChartTooltip check={check} />, tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`, ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0, severity: 0,
date: null,
}) })
); );
return padCells(cells, MAX_RECENT_CHECKS); return padCells(cells, MAX_RECENT_CHECKS);
@@ -111,7 +108,7 @@ export const dailyBucketsToCells = (
const enumerated = enumerateDays(days, bucketTimezone); const enumerated = enumerateDays(days, bucketTimezone);
return enumerated.map((date) => { return enumerated.map((date) => {
const bucket = byDate.get(date); const bucket = byDate.get(date);
if (!bucket) return emptyCell(`day-${date}`, date); if (!bucket) return emptyCell(`day-${date}`);
const upRatio = bucket.upChecks / bucket.totalChecks; const upRatio = bucket.upChecks / bucket.totalChecks;
const barKind = classifyDay(bucket, upRatio); const barKind = classifyDay(bucket, upRatio);
@@ -135,7 +132,6 @@ export const dailyBucketsToCells = (
date, date,
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
}), }),
date,
}; };
}); });
}; };
@@ -1,111 +0,0 @@
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import IconButton from "@mui/material/IconButton";
import { X as XIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { Incident } from "@/Types/Incident";
interface IncidentModalProps {
url: string;
monitorId: string | null;
date: string | null;
timezone: string;
onClose: () => void;
}
export const IncidentModal = ({
url,
monitorId,
date,
timezone,
onClose,
}: IncidentModalProps) => {
const theme = useTheme();
const { t } = useTranslation();
const open = Boolean(monitorId && date);
const { data, isLoading, error } = useGet<{ incidents: Incident[] }>(
open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null,
{},
{ revalidateOnFocus: false, keepPreviousData: true }
);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
>
<DialogTitle
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
{t("pages.statusPages.monitorsList.incidents.modalTitle", { date })} ({timezone})
<IconButton
onClick={onClose}
size="small"
aria-label="close"
>
<XIcon size={20} />
</IconButton>
</DialogTitle>
<DialogContent>
{isLoading ? (
<Typography mt={SPACING.SM}>
{t("pages.statusPages.monitorsList.incidents.loading")}
</Typography>
) : error ? (
<Typography
mt={SPACING.SM}
color={theme.palette.error.main}
>
{t("pages.statusPages.monitorsList.incidents.errorLoading")}
</Typography>
) : data?.incidents && data.incidents.length > 0 ? (
<Stack
gap={SPACING.LG}
mt={SPACING.SM}
>
{data.incidents.map((incident) => (
<Stack
key={incident.id}
p={SPACING.LG}
border={1}
borderColor="divider"
borderRadius={theme.shape.borderRadius}
>
<Typography
variant="subtitle2"
fontWeight="bold"
>
{incident.message ||
(incident.statusCode ? `Error ${incident.statusCode}` : "Incident")}
</Typography>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{formatDateWithTz(incident.startTime, "h:mm A", timezone)} -{" "}
{incident.endTime
? formatDateWithTz(incident.endTime, "h:mm A", timezone)
: t("pages.statusPages.monitorsList.incidents.ongoing")}
</Typography>
</Stack>
))}
</Stack>
) : (
<Typography mt={SPACING.SM}>
{t("pages.statusPages.monitorsList.incidents.noIncidents")}
</Typography>
)}
</DialogContent>
</Dialog>
);
};
@@ -2,8 +2,6 @@ import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
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 { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
import type { import type {
ChartCell, ChartCell,
HeatCellKind, HeatCellKind,
@@ -13,15 +11,17 @@ interface Props {
cells: ChartCell[]; cells: ChartCell[];
containerSx: SxProps<Theme>; containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>; cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
onCellClick?: (date: string) => void;
} }
export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => { export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box <Box
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })} sx={[
...(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")}
> >
@@ -30,15 +30,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props
return ( return (
<Box <Box
key={cell.key} key={cell.key}
sx={mergeSx( sx={cellSx("empty")}
cellSx("empty"),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/> />
); );
} }
@@ -50,16 +42,8 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props
placement="top" placement="top"
> >
<Box <Box
sx={mergeSx( sx={cellSx(cell.heatKind, cell.severity)}
cellSx(cell.heatKind, cell.severity),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
aria-label={cell.ariaLabel} aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/> />
</Tooltip> </Tooltip>
); );
@@ -5,8 +5,6 @@ import Stack from "@mui/material/Stack";
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 { formatMs } from "@/Utils/TimeUtils";
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
import type { import type {
BarKind, BarKind,
ChartCell, ChartCell,
@@ -18,7 +16,6 @@ interface Props {
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>; barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
statsSx: SxProps<Theme>; statsSx: SxProps<Theme>;
statsGap?: number; statsGap?: number;
onCellClick?: (date: string) => void;
} }
export const ThemedHistogram = ({ export const ThemedHistogram = ({
@@ -27,7 +24,6 @@ export const ThemedHistogram = ({
barSx, barSx,
statsSx, statsSx,
statsGap = 1, statsGap = 1,
onCellClick,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,22 +39,17 @@ export const ThemedHistogram = ({
return ( return (
<Stack gap={statsGap}> <Stack gap={statsGap}>
<Box <Box
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })} sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
> >
{cells.map((cell) => { {cells.map((cell) => {
if (cell.barKind === "empty") { if (cell.barKind === "empty") {
return ( return (
<Box <Box
key={cell.key} key={cell.key}
sx={mergeSx( sx={barSx("empty", cell.heightPct)}
barSx("empty", cell.heightPct),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/> />
); );
} }
@@ -70,16 +61,8 @@ export const ThemedHistogram = ({
placement="top" placement="top"
> >
<Box <Box
sx={mergeSx( sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
barSx(cell.barKind, cell.heightPct, cell.severity),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
aria-label={cell.ariaLabel} aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/> />
</Tooltip> </Tooltip>
); );
@@ -1,5 +1,5 @@
export const SANS_STACK = export const SANS_STACK =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`; export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
@@ -1,16 +0,0 @@
import type { SxProps, Theme } from "@mui/material/styles";
export const mergeSx = (
...styles: Array<SxProps<Theme> | false | null | undefined>
): SxProps<Theme> => {
const result: SxProps<Theme>[] = [];
for (const style of styles) {
if (!style) continue;
if (Array.isArray(style)) {
result.push(...(style.filter(Boolean) as SxProps<Theme>[]));
} else {
result.push(style);
}
}
return result as SxProps<Theme>;
};
@@ -24,9 +24,8 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils"; import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils"; import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps { interface MonitorTableProps {
@@ -35,8 +34,9 @@ interface MonitorTableProps {
refetch: () => void; refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void; setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string; sortField: string;
sortOrder: SortOrder; setSortField: (field: string) => void;
setSort: (field: string, order: SortOrder) => void; sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number; count: number;
page: number; page: number;
setPage: (page: number) => void; setPage: (page: number) => void;
@@ -52,8 +52,9 @@ export const MonitorTable = ({
refetch, refetch,
setSelectedMonitor, setSelectedMonitor,
sortField, sortField,
setSortField,
sortOrder, sortOrder,
setSort, setSortOrder,
count, count,
page, page,
setPage, setPage,
@@ -90,8 +91,14 @@ export const MonitorTable = ({
const handleSort = (e: React.MouseEvent, field: string) => { const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const nextSort = getNextMonitorSort(field, sortField, sortOrder); if (sortField === field) {
setSort(nextSort.field, nextSort.order); const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
}; };
const isBrowserOpenable = (monitor: Monitor): boolean => { const isBrowserOpenable = (monitor: Monitor): boolean => {
@@ -343,7 +350,11 @@ export const MonitorTable = ({
onRowClick={(row) => { onRowClick={(row) => {
navigate(`/uptime/${row.id}`); navigate(`/uptime/${row.id}`);
}} }}
isRowSelected={(row) => isRowSelected(row.id)} getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/> />
<Pagination <Pagination
component="div" component="div"
+17 -16
View File
@@ -4,7 +4,7 @@ import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsT
import { useMonitorListController } from "@/Hooks/useMonitorListController"; import { useMonitorListController } from "@/Hooks/useMonitorListController";
const UptimeMonitorsPage = () => { const UptimeMonitorsPage = () => {
const monitorListController = useMonitorListController({ const c = useMonitorListController({
types: "selectable", types: "selectable",
checksLimit: 25, checksLimit: 25,
refreshInterval: 5000, refreshInterval: 5000,
@@ -17,24 +17,25 @@ const UptimeMonitorsPage = () => {
headerKey="uptime" headerKey="uptime"
page="uptime" page="uptime"
actionLink="/uptime/create" actionLink="/uptime/create"
controller={monitorListController} controller={c}
bulkActions bulkActions
> >
<MonitorTable <MonitorTable
monitors={monitorListController.monitors || []} monitors={c.monitors || []}
tags={monitorListController.tags || []} tags={c.tags || []}
refetch={monitorListController.refetch} refetch={c.refetch}
setSelectedMonitor={monitorListController.setSelectedMonitor} setSelectedMonitor={c.setSelectedMonitor}
count={monitorListController.count || 0} count={c.count || 0}
page={monitorListController.page} page={c.page}
setPage={monitorListController.setPage} setPage={c.setPage}
rowsPerPage={monitorListController.rowsPerPage} rowsPerPage={c.rowsPerPage}
sortField={monitorListController.sortField} sortField={c.sortField}
sortOrder={monitorListController.sortOrder} setSortField={c.setSortField}
setSort={monitorListController.setSort} sortOrder={c.sortOrder}
setRowsPerPage={monitorListController.handleSetRowsPerPage} setSortOrder={c.setSortOrder}
selectedRows={monitorListController.selectedRows} setRowsPerPage={c.handleSetRowsPerPage}
onSelectionChange={monitorListController.setSelectedRows} selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
/> />
</MonitorListPage> </MonitorListPage>
); );
-7
View File
@@ -8,9 +8,6 @@ import AuthRegister from "@/Pages/Auth/Register";
import AuthForgotPassword from "@/Pages/Auth/Recovery"; import AuthForgotPassword from "@/Pages/Auth/Recovery";
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword"; import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
// Dashboard
import Dashboard from "@/Pages/Dashboard";
// Uptime // Uptime
import Uptime from "@/Pages/Uptime/Monitors"; import Uptime from "@/Pages/Uptime/Monitors";
import UptimeDetails from "@/Pages/Uptime/Details"; import UptimeDetails from "@/Pages/Uptime/Details";
@@ -95,10 +92,6 @@ const Routes = () => {
path="/" path="/"
element={<Navigate to="/uptime" />} element={<Navigate to="/uptime" />}
/> />
<Route
path="dashboard"
element={<Dashboard />}
/>
<Route <Route
path="/uptime" path="/uptime"
element={<Uptime />} element={<Uptime />}
-25
View File
@@ -161,31 +161,6 @@ export interface DockerContainerMount {
rw: boolean; rw: boolean;
} }
export const DockerLogStreams = ["stdout", "stderr"] as const;
export type DockerLogStream = (typeof DockerLogStreams)[number];
export interface DockerLogLine {
ts: string;
stream: DockerLogStream;
text: string;
}
export interface DockerLog {
id: string;
metadata: {
monitorId: string;
teamId: string;
containerId: string;
containerName: string;
};
lines: DockerLogLine[];
gap: boolean;
checkedAt: string;
expiry: string;
createdAt: string;
updatedAt: string;
}
export interface DockerContainerInfo { export interface DockerContainerInfo {
id: string; id: string;
name: string; name: string;
-10
View File
@@ -1,10 +0,0 @@
export const dashboardCardKeys = [
"monitorStatus",
"currentlyDown",
"uptime",
"monitorsByType",
] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
export const dashboardSortOrders = ["ascending", "descending"] as const;
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
-25
View File
@@ -5,7 +5,6 @@ import type {
DailyCheckBucket, DailyCheckBucket,
DockerContainerInfo, DockerContainerInfo,
DockerContainerSummary, DockerContainerSummary,
DockerLog,
} from "@/Types/Check"; } 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";
@@ -60,15 +59,6 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
pagespeed: "optionPagespeed", pagespeed: "optionPagespeed",
}; };
export const getMonitorTypeLabel = (
type: MonitorType,
t: (key: string) => string
): string => {
const monitorLabel = monitorTypeLabelKey[type];
return monitorLabel ? t(`pages.common.monitors.monitorTypes.${monitorLabel}`) : type;
};
export const MIN_MONITOR_INTERVAL_MS = 10000;
export const MonitorIntervalOptions = [ export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" }, { value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" }, { value: 30000, labelKey: "thirtySeconds" },
@@ -168,12 +158,6 @@ export interface Monitor {
geoCheckEnabled?: boolean; geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[]; geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number; geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerAlertOnStopped?: boolean;
dockerAlertOnUnhealthy?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string; dnsServer?: string;
dnsRecordType?: DnsRecordType; dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
@@ -194,11 +178,6 @@ export interface MonitorsSummary {
breachedMonitors: number; breachedMonitors: number;
} }
export interface MonitorTypeCount {
type: MonitorType;
count: number;
}
export interface MonitorsWithChecksResponse { export interface MonitorsWithChecksResponse {
count: number; count: number;
monitors: MonitorWithChecks[]; monitors: MonitorWithChecks[];
@@ -343,10 +322,6 @@ export interface DockerContainerResponse {
stats: DockerContainerStats; stats: DockerContainerStats;
} }
export interface DockerContainerLogsResponse {
logs: DockerLog[];
nextCursor: string | null;
}
export interface Game { export interface Game {
name: string; name: string;
options?: { options?: {
-1
View File
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"teams", "teams",
"telegram", "telegram",
"pushover", "pushover",
"signalgrid",
"twilio", "twilio",
"ntfy", "ntfy",
] as const; ] as const;
-3
View File
@@ -1,5 +1,2 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const; export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number]; export type DateRange = (typeof DateRanges)[number];
export const SortOrders = ["asc", "desc"] as const;
export type SortOrder = (typeof SortOrders)[number];
-3
View File
@@ -47,6 +47,3 @@ export const getPercentage = (value: number, total: number) => {
if (!value || !total) return 0; if (!value || !total) return 0;
return (value / total) * 100; return (value / total) * 100;
}; };
// Shown wherever a value is not available for the current data source.
export const PLACEHOLDER = "—";
+5 -59
View File
@@ -1,26 +1,12 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { SortOrder } from "@/Types/Query";
import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel"; import type { ValueType } from "@/Components/design-elements/StatusLabel";
import { import type {
DockerLogStreams, DockerContainerMount,
type DockerContainerMount, DockerContainerPort,
type DockerContainerPort, DockerContainerState,
type DockerContainerState,
type DockerLog,
type DockerLogLine,
} from "@/Types/Check"; } from "@/Types/Check";
export const getNextMonitorSort = (
field: string,
sortField: string,
sortOrder: SortOrder
): { field: string; order: SortOrder } => {
if (field !== sortField) return { field, order: "asc" };
if (sortOrder === "asc") return { field, order: "desc" };
return { field: "", order: "asc" };
};
export const getMonitorPath = (type: MonitorType): string => { export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = { const pathMap: Record<MonitorType, string> = {
http: "uptime", http: "uptime",
@@ -31,7 +17,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime", websocket: "uptime",
dns: "uptime", dns: "uptime",
unknown: "uptime", unknown: "uptime",
docker: "docker/host", docker: "docker",
hardware: "infrastructure", hardware: "infrastructure",
pagespeed: "pagespeed", pagespeed: "pagespeed",
}; };
@@ -51,14 +37,6 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
return "warning"; return "warning";
}; };
export const getDockerPalette = (dockerState: DockerContainerState): PaletteKey => {
if (dockerState === "created") return "success";
if (dockerState === "running") return "success";
if (dockerState === "dead") return "error";
if (dockerState === "exited") return "error";
return "warning";
};
export const getValuePalette = (value: ValueType): PaletteKey => { export const getValuePalette = (value: ValueType): PaletteKey => {
const paletteMap: Record<ValueType, PaletteKey> = { const paletteMap: Record<ValueType, PaletteKey> = {
positive: "success", positive: "success",
@@ -166,35 +144,3 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => {
if (mount.source) return mount.source; if (mount.source) return mount.source;
return mount.type; return mount.type;
}; };
export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const;
export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number];
export interface DockerLogRow extends DockerLogLine {
key: string;
gapBefore: boolean; // This signifies lines skipped
}
export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => {
const rows: DockerLogRow[] = [];
for (const log of [...logs].reverse()) {
log.lines.forEach((line: DockerLogLine, idx: number) => {
rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap });
});
}
return rows;
};
export const filterDockerLogRows = (
rows: DockerLogRow[],
stream: DockerLogStreamFilter,
query: string
): DockerLogRow[] => {
const normalizedQuery = query.trim().toLowerCase();
return rows.filter((row) => {
return (
(stream === "all" || row.stream === stream) &&
(normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery))
);
});
};
-61
View File
@@ -18,20 +18,6 @@ export const colors = {
gray700: "#313131", gray700: "#313131",
gray900: "#151518", gray900: "#151518",
gray850: "#1c1c21", gray850: "#1c1c21",
ink900: "#101917",
ink600: "#55635C",
ink400: "#7C8983",
surface50: "#F6F8F7",
rule200: "#C7D2CC",
successMain: "#0D6049",
errorMain: "#A02B24",
warningMain: "#8C5806",
inkDark900: "#ECEFED",
inkDark600: "#A3ADA8",
ruleDark: "#3A4642",
successDark: "#4BC49E",
errorDark: "#E8837A",
warningDark: "#E0A458",
brandGreen: "#13715B", brandGreen: "#13715B",
brandGreenLight: "#4DAF94", brandGreenLight: "#4DAF94",
green200: "#ECF7F2", green200: "#ECF7F2",
@@ -57,38 +43,13 @@ export const lightPalette = {
action: { action: {
rowHover: alpha(colors.gray900, HOVER.ROW), rowHover: alpha(colors.gray900, HOVER.ROW),
controlHover: alpha(colors.gray900, HOVER.CONTROL), controlHover: alpha(colors.gray900, HOVER.CONTROL),
selected: alpha(colors.brandGreen, 0.06),
selectedHover: alpha(colors.brandGreen, 0.1),
}, },
secondary: { secondary: {
main: colors.gray200, main: colors.gray200,
}, },
background: {
default: colors.surface50,
paper: "#FFFFFF",
},
text: {
primary: colors.ink900,
secondary: colors.ink600,
disabled: colors.ink400,
},
divider: colors.rule200,
success: {
main: colors.successMain,
},
error: {
main: colors.errorMain,
},
warning: {
main: colors.warningMain,
},
sidebar: { sidebar: {
accent: colors.brandGreen, accent: colors.brandGreen,
}, },
scrollbar: {
track: colors.rule200,
thumb: colors.brandGreen,
},
rowStatus: { rowStatus: {
running: colors.green200, running: colors.green200,
paused: colors.yellow200, paused: colors.yellow200,
@@ -108,13 +69,10 @@ export const lightPalette = {
export const darkPalette = { export const darkPalette = {
primary: { primary: {
main: colors.brandGreen, main: colors.brandGreen,
light: colors.brandGreenLight,
}, },
action: { action: {
rowHover: alpha("#FFFFFF", HOVER.ROW), rowHover: alpha("#FFFFFF", HOVER.ROW),
controlHover: alpha("#FFFFFF", HOVER.CONTROL), controlHover: alpha("#FFFFFF", HOVER.CONTROL),
selected: alpha(colors.brandGreenLight, 0.07),
selectedHover: alpha(colors.brandGreenLight, 0.11),
}, },
secondary: { secondary: {
main: colors.gray700, main: colors.gray700,
@@ -123,28 +81,9 @@ export const darkPalette = {
default: colors.gray900, default: colors.gray900,
paper: colors.gray850, paper: colors.gray850,
}, },
text: {
primary: colors.inkDark900,
secondary: colors.inkDark600,
disabled: colors.ink400,
},
divider: colors.ruleDark,
success: {
main: colors.successDark,
},
error: {
main: colors.errorDark,
},
warning: {
main: colors.warningDark,
},
sidebar: { sidebar: {
accent: colors.brandGreenLight, accent: colors.brandGreenLight,
}, },
scrollbar: {
track: colors.ruleDark,
thumb: colors.brandGreen,
},
rowStatus: { rowStatus: {
running: alpha(colors.brandGreen, 0.18), running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18), paused: alpha(colors.orange500, 0.18),
+10 -61
View File
@@ -3,7 +3,6 @@ import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palet
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import type {} from "@mui/material/styles"; import type {} from "@mui/material/styles";
import { SCROLLBAR_SIZE } from "@/Utils/Theme/constants";
declare module "@mui/material/styles" { declare module "@mui/material/styles" {
interface TypographyVariants { interface TypographyVariants {
@@ -17,11 +16,9 @@ declare module "@mui/material/styles" {
interface TypeAction { interface TypeAction {
rowHover: string; rowHover: string;
controlHover: string; controlHover: string;
selectedHover: string;
} }
interface Palette { interface Palette {
sidebar: { accent: string }; sidebar: { accent: string };
scrollbar: { track: string; thumb: string };
rowStatus: { running: string; paused: string }; rowStatus: { running: string; paused: string };
chart: { chart: {
phases: { phases: {
@@ -36,7 +33,6 @@ declare module "@mui/material/styles" {
} }
interface PaletteOptions { interface PaletteOptions {
sidebar?: { accent: string }; sidebar?: { accent: string };
scrollbar?: { track: string; thumb: string };
rowStatus?: { running: string; paused: string }; rowStatus?: { running: string; paused: string };
chart?: { chart?: {
phases: { phases: {
@@ -61,7 +57,7 @@ export type PaletteKey = {
[K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never; [K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never;
}[keyof Theme["palette"]]; }[keyof Theme["palette"]];
const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif'; const fontFamilyPrimary = "system-ui, sans-serif";
const fontFamilyMonospace = const fontFamilyMonospace =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
const shadow = const shadow =
@@ -89,13 +85,11 @@ export const theme = (mode: string, palette: any) =>
fontSize: typographyLevels.base, fontSize: typographyLevels.base,
h1: { h1: {
fontSize: typographyLevels.xxl, fontSize: typographyLevels.xxl,
fontWeight: 700, fontWeight: 500,
letterSpacing: "-0.015em",
}, },
h2: { h2: {
fontSize: typographyLevels.l, fontSize: typographyLevels.l,
fontWeight: 600, fontWeight: 400,
letterSpacing: "-0.01em",
}, },
body1: { body1: {
fontSize: typographyLevels.m, fontSize: typographyLevels.m,
@@ -115,27 +109,6 @@ export const theme = (mode: string, palette: any) =>
}, },
components: { components: {
MuiCssBaseline: {
styleOverrides: (theme) => ({
"*": {
scrollbarWidth: "auto",
scrollbarColor: `${theme.palette.scrollbar.thumb} ${theme.palette.scrollbar.track}`,
},
"*::-webkit-scrollbar": {
width: SCROLLBAR_SIZE,
height: SCROLLBAR_SIZE,
},
"*::-webkit-scrollbar-track": {
background: theme.palette.scrollbar.track,
borderRadius: theme.shape.borderRadius,
},
"*::-webkit-scrollbar-thumb": {
background: theme.palette.scrollbar.thumb,
borderRadius: theme.shape.borderRadius,
},
"*::-webkit-scrollbar-button": { display: "none" },
}),
},
MuiTouchRipple: { MuiTouchRipple: {
styleOverrides: { styleOverrides: {
root: { root: {
@@ -158,11 +131,7 @@ export const theme = (mode: string, palette: any) =>
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ root: ({ theme }) => ({
"&.Mui-focusVisible": { "&.Mui-focusVisible": {
outline: `1px solid ${ outline: `1px solid ${theme.palette.primary.main}`,
theme.palette.mode === "dark"
? theme.palette.primary.light
: theme.palette.primary.main
}`,
outlineOffset: 2, outlineOffset: 2,
}, },
}), }),
@@ -216,38 +185,18 @@ export const theme = (mode: string, palette: any) =>
}), }),
}, },
}, },
MuiOutlinedInput: { MuiOutlinedInput: {
styleOverrides: { styleOverrides: {
input: {
paddingTop: 0,
paddingBottom: 0,
height: "100%",
},
root: ({ theme }) => ({ root: ({ theme }) => ({
"& .MuiSelect-select": { "&:hover .MuiOutlinedInput-notchedOutline": {
paddingTop: 0,
paddingBottom: 0,
display: "flex",
alignItems: "center",
minHeight: "unset",
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
transition: "border-color 0.15s ease",
},
"&:hover:not(.Mui-focused):not(.Mui-disabled) .MuiOutlinedInput-notchedOutline":
{
borderColor: theme.palette.text.disabled,
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: borderColor:
theme.palette.mode === "dark" theme.palette.mode === "dark"
? theme.palette.primary.light ? "rgba(255, 255, 255, 0.23)"
: theme.palette.primary.main, : theme.palette.text.primary,
borderWidth: 2,
}, },
"&.Mui-error .MuiOutlinedInput-notchedOutline": { "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.error.main, borderColor: palette.primary.main,
}, },
}), }),
}, },
-2
View File
@@ -18,8 +18,6 @@ export const LAYOUT = {
XXL: 16, XXL: 16,
} as const; } as const;
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
export const SCROLLBAR_SIZE = 16;
export const HOVER = { export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function DARKEN: 0.06, // This is a coefficient for darkening function
ROW: 0.025, // Overlay alpha for hoverable rows and cards ROW: 0.025, // Overlay alpha for hoverable rows and cards
+3 -46
View File
@@ -104,52 +104,9 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => {
return formatted; return formatted;
}; };
type DurationUnit = "day" | "hour" | "minute" | "second"; export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => {
if (verbose) {
interface FormatDurationOptions { return prettyMilliseconds(ms, { verbose: true });
long?: boolean;
relative?: boolean;
hasSpace?: boolean;
locale?: string;
}
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
{ unit: "day", milliseconds: MS_PER_DAY },
{ unit: "hour", milliseconds: MS_PER_HOUR },
{ unit: "minute", milliseconds: MS_PER_MINUTE },
{ unit: "second", milliseconds: MS_PER_SECOND },
];
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
const getDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
FALLBACK_DURATION_UNIT;
const getExactDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
FALLBACK_DURATION_UNIT;
export const formatDuration = (
ms: number,
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
): string => {
if (relative) {
const { unit, milliseconds } = getDurationUnit(ms);
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
return new Intl.RelativeTimeFormat(locale, {
numeric: "always",
style: long ? "long" : "narrow",
}).format(-value, unit);
}
if (long) {
const { unit, milliseconds } = getExactDurationUnit(ms);
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
}).format(ms / milliseconds);
} }
const formatted = prettyMilliseconds(ms, { const formatted = prettyMilliseconds(ms, {
+4 -79
View File
@@ -6,7 +6,6 @@ import {
HttpMethods, HttpMethods,
PageSpeedStrategies, PageSpeedStrategies,
ProxyModes, ProxyModes,
MIN_MONITOR_INTERVAL_MS,
type MonitorType, type MonitorType,
} from "@/Types/Monitor"; } from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -28,8 +27,7 @@ const baseSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
interval: z interval: z
.number() .number()
.int("Interval must be a whole number of milliseconds") .min(15000, "Interval must be at least 15 seconds")
.min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
.register(monitorStepRegistry, { step: 1 }), .register(monitorStepRegistry, { step: 1 }),
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }), notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }), tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
@@ -110,50 +108,10 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"), .max(65535, "Port must be at most 65535"),
}); });
// Docker monitor schema. The URL forms mirror the Docker source helpers in // Docker monitor schema
// server/src/utils/dockerHost.ts.
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean =>
dockerTlsUrlRegex.test(url?.trim() ?? "");
export const isCaptureDockerUrl = (url: string | undefined): boolean => {
try {
const parsed = new URL(url?.trim() ?? "");
const pathname = parsed.pathname.replace(/\/+$/, "");
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
pathname.endsWith("/metrics/docker")
);
} catch {
return false;
}
};
const dockerSchema = baseSchema.extend({ const dockerSchema = baseSchema.extend({
type: z.literal("docker"), type: z.literal("docker"),
url: z url: z.string().min(1, "Container ID is required"),
.string()
.min(1, "Docker host URL is required")
.refine(
(url) =>
dockerSocketUrlRegex.test(url.trim()) ||
isDockerTlsUrl(url) ||
isCaptureDockerUrl(url),
"Docker host must be a socket path, a TLS daemon URL, or a Capture /metrics/docker endpoint"
),
secret: z.string(),
ignoreTlsErrors: z.boolean(),
dockerTlsCa: z.string(),
dockerTlsCert: z.string(),
// blank on edit keeps the stored key.
dockerTlsKey: z.string(),
// Read only mirror of the server flag, stripped on submit
dockerTlsKeySet: z.boolean(),
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
dockerAlertOnStopped: z.boolean().register(monitorStepRegistry, { step: 1 }),
dockerAlertOnUnhealthy: z.boolean().register(monitorStepRegistry, { step: 1 }),
}); });
// Game server monitor schema // Game server monitor schema
@@ -258,8 +216,7 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema, dnsSchema,
]); ]);
// Mirrors the server's refineProxySelection and refineDockerTls // Mirrors the server's refineProxySelection (monitorValidation.ts)
// (monitorValidation.ts). PEM contents are only parsed on the server.
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) { if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({ ctx.addIssue({
@@ -268,38 +225,6 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom", message: "A proxy must be selected when proxy mode is custom",
}); });
} }
if (data.type === "docker" && isCaptureDockerUrl(data.url) && !data.secret.trim()) {
ctx.addIssue({
code: "custom",
path: ["secret"],
message: "Capture API secret is required",
});
}
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
if (!data.dockerTlsCert.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCert"],
message: "Client certificate is required for a TLS Docker host",
});
}
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsKey"],
message: "Client key is required for a TLS Docker host",
});
}
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCa"],
message: "CA certificate is required unless TLS errors are ignored",
});
}
}
}); });
export type MonitorFormData = z.infer<typeof monitorSchema>; export type MonitorFormData = z.infer<typeof monitorSchema>;
-7
View File
@@ -70,12 +70,6 @@ const pushoverSchema = baseSchema.extend({
accessToken: z.string().min(1, "App token is required"), accessToken: z.string().min(1, "App token is required"),
}); });
const signalgridSchema = baseSchema.extend({
type: z.literal("signalgrid"),
address: z.string().min(1, "Channel is required"),
accessToken: z.string().min(1, "Client key is required"),
});
const twilioSchema = baseSchema.extend({ const twilioSchema = baseSchema.extend({
type: z.literal("twilio"), type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"), accountSid: z.string().min(1, "Account SID is required"),
@@ -101,7 +95,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
teamsSchema, teamsSchema,
telegramSchema, telegramSchema,
pushoverSchema, pushoverSchema,
signalgridSchema,
twilioSchema, twilioSchema,
ntfySchema, ntfySchema,
]); ]);
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 536 KiB

+2
View File
@@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
-3
View File
@@ -1377,9 +1377,6 @@
"maintenance": "صيانة", "maintenance": "صيانة",
"paused": "متوقف مؤقتاً", "paused": "متوقف مؤقتاً",
"up": "قيد التشغيل" "up": "قيد التشغيل"
},
"incidents": {
"errorLoading": "فشل تحميل الحوادث."
} }
}, },
"statusBar": { "statusBar": {
-3
View File
@@ -1377,9 +1377,6 @@
"uptime": { "uptime": {
"title": "Temps en línia", "title": "Temps en línia",
"responseTime": "Temps de resposta" "responseTime": "Temps de resposta"
},
"incidents": {
"errorLoading": "No s'han pogut carregar les incidències."
} }
}, },
"statusBar": { "statusBar": {
-3
View File
@@ -1377,9 +1377,6 @@
"maintenance": "Údržba", "maintenance": "Údržba",
"paused": "Pozastaveno", "paused": "Pozastaveno",
"up": "V provozu" "up": "V provozu"
},
"incidents": {
"errorLoading": "Nepodařilo se načíst incidenty."
} }
}, },
"statusBar": { "statusBar": {
-4
View File
@@ -448,7 +448,6 @@
}, },
"statBoxes": { "statBoxes": {
"activeFor": "Aktiv seit", "activeFor": "Aktiv seit",
"checkingEvery": "Check alle {{interval}}",
"certificateExpiry": "Zertifikatablauf", "certificateExpiry": "Zertifikatablauf",
"lastCheck": "Letzter Check", "lastCheck": "Letzter Check",
"lastResponseTime": "Letzte Antwortzeit", "lastResponseTime": "Letzte Antwortzeit",
@@ -1378,9 +1377,6 @@
"maintenance": "Wartung", "maintenance": "Wartung",
"paused": "Pausiert", "paused": "Pausiert",
"up": "Betriebsbereit" "up": "Betriebsbereit"
},
"incidents": {
"errorLoading": "Vorfälle konnten nicht geladen werden."
} }
}, },
"statusBar": { "statusBar": {
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff. Show more