Compare commits

..
Author SHA1 Message Date
Alexander Holliday 021f99e0be Merge pull request #3889 from bluewave-labs/develop
develop -> master
2026-08-26 12:08:53 -07:00
Alexander Holliday adc6af83e3 Merge pull request #3885 from bluewave-labs/develop
Develop -> Master
2026-08-26 10:45:29 -07:00
Alexander Holliday 872e5f32cb Merge pull request #3807 from bluewave-labs/develop
develop -> master
2026-07-30 09:39:06 -07:00
Alexander Holliday d368ec5bd7 Merge pull request #3761 from bluewave-labs/develop
develop -> master
2026-07-07 10:19:52 -07:00
Alexander Holliday 825bcc5fdb Merge pull request #3751 from bluewave-labs/develop
develop -> master
2026-07-02 11:06:47 -07:00
Alexander Holliday 69cdf948da Merge pull request #3750 from bluewave-labs/develop
develop -> master
2026-07-02 10:57:17 -07:00
Alexander Holliday d337bb235c Merge pull request #3749 from bluewave-labs/develop
migration fix
2026-07-02 10:43:59 -07:00
Alexander Holliday 0c0f43174d Merge pull request #3748 from bluewave-labs/develop
fix/migration
2026-07-02 10:30:55 -07:00
Alexander Holliday 51149d1dd4 Merge pull request #3747 from bluewave-labs/develop
develop -> master
2026-07-02 10:18:55 -07:00
Alexander Holliday 448fcc4628 Merge pull request #3707 from bluewave-labs/develop
develop -> master
2026-06-05 12:04:15 -07:00
Alexander Holliday ee5462d039 Merge pull request #3699 from bluewave-labs/develop
develop -> master
2026-06-05 10:39:06 -07:00
Alexander Holliday 7ee6d342e9 Merge pull request #3606 from bluewave-labs/develop
develop -> master
2026-04-29 12:11:29 -07:00
Alexander Holliday 49d3737a93 Merge pull request #3604 from bluewave-labs/develop
develop -> master
2026-04-29 11:51:43 -07:00
Alexander Holliday 4402e4fbfa Merge pull request #3567 from bluewave-labs/develop
develop -> master
2026-04-23 11:26:21 -07:00
Alexander Holliday 721f77ed26 Merge pull request #3565 from bluewave-labs/develop
develop -> master
2026-04-23 10:15:31 -07:00
Alexander Holliday 34f29e5ae6 Merge pull request #3563 from bluewave-labs/develop
develop -> master
2026-04-23 09:52:58 -07:00
Alexander Holliday c2170267e8 Merge pull request #3394 from bluewave-labs/develop
develop -> master 3.5.1 release
2026-03-06 12:56:20 -08:00
Alexander Holliday 321ca59380 Merge pull request #3368 from bluewave-labs/develop
Develop -> master (3.5.0)
2026-03-04 10:41:02 -08:00
Alexander Holliday e3ecfe6ad8 Merge pull request #3323 from bluewave-labs/develop
develop -> master
2026-02-20 12:38:33 -08:00
Alexander Holliday f4664eec16 Merge pull request #3321 from bluewave-labs/develop
develop->master
2026-02-20 12:20:49 -08:00
Alexander Holliday 56f601e34b Merge pull request #3319 from bluewave-labs/develop
develop -> master
2026-02-20 12:07:29 -08:00
Alexander Holliday 1244ff6d93 Merge pull request #3212 from bluewave-labs/develop
develop -> master
2026-01-27 13:58:48 -08:00
Alexander Holliday c0ea804c98 Merge pull request #3209 from bluewave-labs/develop
develop -> master
2026-01-27 13:40:25 -08:00
Alexander Holliday c21cf29db6 Merge pull request #3207 from bluewave-labs/develop
develop -> master
2026-01-27 13:20:18 -08:00
Alexander Holliday 3d0da70ce6 Merge pull request #3204 from bluewave-labs/develop
develop -> master
2026-01-27 13:09:47 -08:00
Alexander Holliday feb061240b Merge pull request #3042 from bluewave-labs/develop
develp->master
2025-10-27 12:09:00 -07:00
Alexander Holliday a924227805 Merge pull request #2964 from bluewave-labs/develop
develop -> master
2025-09-18 13:40:45 -07:00
Alexander Holliday 2144d3f8c0 Merge pull request #2962 from bluewave-labs/develop
develop -> master
2025-09-18 12:04:51 -07:00
Alexander Holliday fcd6786149 Merge pull request #2936 from bluewave-labs/develop
develop -> master
2025-09-08 20:43:21 -07:00
Alexander Holliday ea968ebf3c Merge pull request #2933 from bluewave-labs/develop
develop -> master
2025-09-08 20:33:35 -07:00
Alexander Holliday 8a7b852974 Merge pull request #2929 from bluewave-labs/develop
develop -> master
2025-09-08 07:24:16 -07:00
Alexander Holliday c8d3e3b70c Merge pull request #2921 from bluewave-labs/develop
develop -> master
2025-09-04 15:46:30 -07:00
Alexander Holliday 7a90a6f9f4 Merge pull request #2915 from bluewave-labs/develop
develop->master
2025-09-01 10:33:09 -07:00
191 changed files with 3807 additions and 10589 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
+1 -1
View File
@@ -109,7 +109,7 @@ client/src/
- **Data Fetching**: SWR + Axios - **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM - **Database**: MongoDB with Mongoose ODM
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling) - **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service) - **i18n**: i18next + react-i18next (translations via PoEditor)
--- ---
+11 -125
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), [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,119 +119,12 @@ 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](./docs/custom-ca-trust.md) for Docker configuration options.
### Docker monitors
A Docker monitor connects to a Docker daemon and reports on every container it runs. The daemon's ping response decides whether the monitor is up or down and its latency is the response time. Each check also records every container's state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every check; logs are kept for 7 days.
The **Docker host** field accepts two forms:
| Host | Example | Notes |
|---|---|---|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
```yaml
services:
checkmate:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "989" # the gid printed by stat
```
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
### Securing the Docker daemon with TLS
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
```bash
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
```
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
```bash
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
cat > server-ext.cnf <<EOF
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
```
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
```bash
openssl genrsa -out key.pem 4096
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
rm client.csr server.csr server-ext.cnf client-ext.cnf
chmod 0400 ca-key.pem key.pem server-key.pem
chmod 0444 ca.pem server-cert.pem cert.pem
```
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/certs/ca.pem",
"tlscert": "/etc/docker/certs/server-cert.pem",
"tlskey": "/etc/docker/certs/server-key.pem"
}
```
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
```bash
sudo systemctl edit docker.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
```
```bash
sudo systemctl daemon-reload && sudo systemctl restart docker
```
Open port `2376` on the host firewall only to the machine running Checkmate.
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
```bash
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
-H=docker.example.com:2376 version
```
For more documentation, see the [docs directory](./docs/). For more documentation, see the [docs directory](./docs/).
<a id="performance"></a> <a id="performance"></a>
@@ -248,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)
@@ -266,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
@@ -326,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!
+4 -34
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",
@@ -93,7 +92,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -409,7 +407,6 @@
"node_modules/@emotion/react": { "node_modules/@emotion/react": {
"version": "11.14.0", "version": "11.14.0",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +444,6 @@
"node_modules/@emotion/styled": { "node_modules/@emotion/styled": {
"version": "11.14.1", "version": "11.14.1",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -954,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"
@@ -1236,7 +1223,6 @@
"node_modules/@mui/material": { "node_modules/@mui/material": {
"version": "7.3.7", "version": "7.3.7",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7", "@mui/core-downloads-tracker": "^7.3.7",
@@ -1341,7 +1327,6 @@
"node_modules/@mui/system": { "node_modules/@mui/system": {
"version": "7.3.7", "version": "7.3.7",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7", "@mui/private-theming": "^7.3.7",
@@ -2386,7 +2371,6 @@
"version": "24.5.2", "version": "24.5.2",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.12.0" "undici-types": "~7.12.0"
} }
@@ -2402,7 +2386,6 @@
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.27", "version": "18.3.27",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -2493,7 +2476,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0", "@typescript-eslint/types": "7.18.0",
@@ -2774,7 +2756,6 @@
"version": "8.15.0", "version": "8.15.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3131,7 +3112,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.42", "baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800", "caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3555,7 @@
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.13", "version": "1.11.13",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
@@ -3975,7 +3954,6 @@
"version": "8.57.1", "version": "8.57.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4766,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.27.6" "@babel/runtime": "^7.27.6"
}, },
@@ -5600,7 +5577,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@mapbox/geojson-rewind": "^0.5.2", "@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6382,7 +6358,6 @@
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -6394,7 +6369,6 @@
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.71.1", "version": "7.71.1",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -6598,8 +6572,7 @@
}, },
"node_modules/redux": { "node_modules/redux": {
"version": "5.0.1", "version": "5.0.1",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-persist": { "node_modules/redux-persist": {
"version": "6.0.0", "version": "6.0.0",
@@ -6749,7 +6722,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -7591,7 +7563,6 @@
"version": "5.9.3", "version": "5.9.3",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -7729,7 +7700,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
+2 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.12.0", "version": "3.11.0",
"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",
@@ -12,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"
@@ -22,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 -10
View File
@@ -3,7 +3,6 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { useTheme } from "@mui/material/styles";
export type ActionMenuItem = { export type ActionMenuItem = {
id: number | string; id: number | string;
@@ -13,7 +12,6 @@ export type ActionMenuItem = {
}; };
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const theme = useTheme();
const [anchorEl, setAnchorEl] = useState<null | any>(null); const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
@@ -29,14 +27,7 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return ( return (
<div> <div>
<IconButton <IconButton onClick={handleClick}>
onClick={handleClick}
sx={{
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
}}
>
<Settings <Settings
size={20} size={20}
strokeWidth={1.5} strokeWidth={1.5}
@@ -6,13 +6,12 @@ import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress"; import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material"; import { useMediaQuery } from "@mui/material";
import type { DateRange } from "@/Types/Query";
interface MonitorTimeFrameHeaderProps { interface MonitorTimeFrameHeaderProps {
isLoading?: boolean; isLoading?: boolean;
hasDateRange?: boolean; hasDateRange?: boolean;
dateRange: DateRange; dateRange: string;
setDateRange: (dateRange: DateRange) => void; setDateRange: (dateRange: string) => void;
} }
export const HeaderTimeRange = ({ export const HeaderTimeRange = ({
@@ -26,7 +25,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md")); const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = ( const handleChange = (
_event: React.MouseEvent<HTMLElement>, _event: React.MouseEvent<HTMLElement>,
newValue: DateRange | null newValue: string | null
) => { ) => {
if (newValue !== null) { if (newValue !== null) {
setDateRange(newValue); setDateRange(newValue);
@@ -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";
@@ -10,10 +10,8 @@ import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{ type BaseChartProps = React.PropsWithChildren<{
icon?: React.ReactNode; icon?: React.ReactNode;
title: string; title: string;
rightTitle?: string;
width?: number | string; width?: number | string;
maxWidth?: number | string; maxWidth?: number | string;
flexBasis?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>; padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void; onClick?: () => void;
}>; }>;
@@ -21,10 +19,8 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({ export const BaseChart = ({
children, children,
title, title,
rightTitle,
width = "100%", width = "100%",
maxWidth = "100%", maxWidth = "100%",
flexBasis = "0%",
padding, padding,
onClick, onClick,
}: BaseChartProps) => { }: BaseChartProps) => {
@@ -36,7 +32,6 @@ export const BaseChart = ({
padding: padding ?? theme.spacing(LAYOUT.MD), padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex", display: "flex",
flex: 1, flex: 1,
flexBasis,
width: width, width: width,
maxWidth: maxWidth, maxWidth: maxWidth,
...(onClick && { ...(onClick && {
@@ -49,27 +44,13 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(LAYOUT.MD)}
flex={1} flex={1}
> >
<Stack <Typography
direction="row" variant="eyebrow"
justifyContent={"space-between"} color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
> >
<Typography {title}
variant="eyebrow" </Typography>
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
{rightTitle && (
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{rightTitle}
</Typography>
)}
</Stack>
<Box flex={1}>{children}</Box> <Box flex={1}>{children}</Box>
</Stack> </Stack>
</BaseBox> </BaseBox>
@@ -83,17 +83,12 @@ export const Breadcrumb = ({
const basePage = segments[0] || t("common.breadcrumbs.home"); const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1]; const secondSegment = segments[1];
const thirdSegment = segments[2];
const isActionPage = secondSegment && actionSegments.includes(secondSegment); const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment); const isDetailsPage = secondSegment && isId(secondSegment);
// page/section/:id routes such as docker/host/:monitorId const hasSubPage = isActionPage || isDetailsPage;
const isSectionDetailsPage = Boolean(
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
);
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const getSubPageLabel = (): string => { const getSubPageLabel = (): string => {
if (isActionPage || isSectionDetailsPage) { if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1); return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
} }
return t("common.breadcrumbs.details"); return t("common.breadcrumbs.details");
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
() => ({ () => ({
circumference: 2 * Math.PI * radius, circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2, totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius), strokeLength: (progress / 100) * (2 * Math.PI * radius),
}), }),
[radius, strokeWidth, progressWithinRange] [radius, strokeWidth, progress]
); );
const [offset, setOffset] = useState(circumference); const [offset, setOffset] = useState(circumference);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [circumference, strokeLength]); }, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme); const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,7 +123,6 @@ export const DetailGauge = ({
lowerLabel, lowerLabel,
lowerValue, lowerValue,
maxWidth = 225, maxWidth = 225,
flexBasis = "0%",
}: { }: {
title: string; title: string;
progress: number; progress: number;
@@ -132,7 +131,6 @@ export const DetailGauge = ({
lowerLabel?: string; lowerLabel?: string;
lowerValue?: string | number; lowerValue?: string | number;
maxWidth?: number; maxWidth?: number;
flexBasis?: number | string;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
@@ -140,7 +138,6 @@ export const DetailGauge = ({
icon={null} icon={null}
title={title} title={title}
maxWidth={maxWidth} maxWidth={maxWidth}
flexBasis={flexBasis}
> >
<Stack <Stack
alignItems={"center"} alignItems={"center"}
@@ -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}
@@ -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";
@@ -11,18 +11,17 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{ type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey; palette?: PaletteKey;
sx?: SxProps; sx?: SxProps;
interactive?: boolean;
}>; }>;
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => { export const GradientBox = ({ children, palette, sx }: GradientBox) => {
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light"; const isLight = theme.palette.mode === "light";
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 (
@@ -31,12 +30,6 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
padding: `${theme.spacing(4)} ${theme.spacing(8)}`, padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`, width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg, background: bg,
...(interactive && {
cursor: "pointer",
"&:hover": {
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
},
}),
...sx, ...sx,
}} }}
> >
@@ -69,8 +62,10 @@ export const StatBox = ({
return ( return (
<GradientBox <GradientBox
palette={palette} palette={palette}
interactive={Boolean(onClick)} sx={{
sx={{ ...(sx as object) }} ...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
> >
<Stack onClick={onClick}> <Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}> <Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
@@ -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:
@@ -1,19 +1,12 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements"; import { BaseBox } from "@/Components/design-elements";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { MonitorStatus } from "@/Types/Monitor"; import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles"; import type { SxProps } from "@mui/material/styles";
import { import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
getDockerStatePalette,
getStatusPalette,
getValuePalette,
} from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DockerContainerState } from "@/Types/Check";
import { SPACING } from "@/Utils/Theme/constants";
export const ValueTypes = ["positive", "negative", "neutral"] as const; export const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number]; export type ValueType = (typeof ValueTypes)[number];
@@ -117,36 +110,3 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
</BaseBox> </BaseBox>
); );
}; };
export const DockerStateLabel = ({
state,
name,
image,
}: {
state: DockerContainerState;
name: string;
image: string;
}) => {
const palette = getDockerStatePalette(state);
const theme = useTheme();
return (
<Stack
direction="row"
gap={SPACING.LG}
alignItems={"center"}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
<Stack>
<Typography>{name}</Typography>
<Typography variant="body2">{image}</Typography>
</Stack>
</Stack>
);
};
@@ -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);
@@ -82,22 +80,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;
if (data.length === 0 || headers.length === 0) { if (data.length === 0 || headers.length === 0) {
return ( return (
<EmptyView <EmptyView
@@ -117,10 +99,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",
@@ -129,11 +108,6 @@ export function DataTable<
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS), padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default", cursor: onRowClick ? "pointer" : "default",
...(onRowClick && {
"&:hover": {
backgroundColor: theme.palette.action.rowHover,
},
}),
}} }}
key={key} key={key}
> >
@@ -217,7 +191,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)}`,
@@ -253,29 +227,15 @@ 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
sx={{ sx={{
cursor: isInteractive ? "pointer" : "default", cursor: onRowClick ? "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);
}} }}
@@ -332,12 +292,6 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void; onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
} }
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
"&:hover": {
backgroundColor: theme.palette.action.controlHover,
},
});
function TablePaginationActions(props: TablePaginationActionsProps) { function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme(); const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props; const { count, page, rowsPerPage, onPageChange } = props;
@@ -367,7 +321,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick} onClick={handleFirstPageButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="first page" aria-label="first page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronsRight <ChevronsRight
@@ -385,7 +338,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleBackButtonClick} onClick={handleBackButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="previous page" aria-label="previous page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronRight <ChevronRight
@@ -403,7 +355,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleNextButtonClick} onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1} disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page" aria-label="next page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronLeft <ChevronLeft
@@ -421,7 +372,6 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleLastPageButtonClick} onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1} disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page" aria-label="last page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronsLeft <ChevronsLeft
@@ -469,7 +419,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleFirstPageButtonClick} onClick={handleFirstPageButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="first page" aria-label="first page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronsRight <ChevronsRight
@@ -487,7 +436,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleBackButtonClick} onClick={handleBackButtonClick}
disabled={page === 0} disabled={page === 0}
aria-label="previous page" aria-label="previous page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronRight <ChevronRight
@@ -505,7 +453,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
onClick={handleNextButtonClick} onClick={handleNextButtonClick}
disabled={hasMore === false} disabled={hasMore === false}
aria-label="next page" aria-label="next page"
sx={controlHoverSx(theme)}
> >
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronLeft <ChevronLeft
@@ -523,7 +470,6 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
<IconButton <IconButton
disabled={hasMore === false} disabled={hasMore === false}
aria-label="next page" aria-label="next page"
sx={controlHoverSx(theme)}
> >
<Ellipsis <Ellipsis
size={20} size={20}
@@ -1,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,
}, },
@@ -9,11 +9,12 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material"; import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "game", "grpc", "websocket"]; const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = { const typeDisplayNames: Record<string, string> = {
http: "HTTP", http: "HTTP",
ping: "Ping", ping: "Ping",
port: "Port", port: "Port",
docker: "Docker",
game: "Game", game: "Game",
grpc: "gRPC", grpc: "gRPC",
websocket: "WebSocket", websocket: "WebSocket",
@@ -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"
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = controller.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={controller.error || extraError}
totalCount={controller.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={controller.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: controller.selectedTypes,
setSelectedTypes: controller.setSelectedTypes,
})}
selectedStatus={controller.selectedStatus}
setSelectedStatus={controller.setSelectedStatus}
selectedState={controller.selectedState}
setSelectedState={controller.setSelectedState}
tagOptions={controller.tags ?? []}
selectedTags={controller.selectedTags}
setSelectedTags={controller.setSelectedTags}
onClearFilters={controller.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={controller.search}
onChange={(event) => {
controller.setSearch(event.target.value);
}}
/>
</Stack>
{controller.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{controller.selectedTags.map((tagId) => {
const tag = controller.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={controller.selectedRows.length}
onCancel={controller.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={controller.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={controller.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={controller.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={controller.handleConfirmDelete}
onCancel={controller.handleCancelDelete}
loading={controller.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -0,0 +1,34 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -1,113 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
export const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
export const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
@@ -1,132 +0,0 @@
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import Typography from "@mui/material/Typography";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
import type { DateRange } from "@/Types/Query";
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
dataKey: string;
range: string;
theme: Theme;
uiTimezone: string;
valueFormatter: (value: number) => string;
labelKey: string;
};
const DockerHistogramToolTip = ({
dataKey,
active,
payload,
range,
theme,
uiTimezone,
valueFormatter,
labelKey,
}: DockerHistogramToolTipProps) => {
const { t } = useTranslation();
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const ts = payload[0].payload?._id ?? null;
const dataPoint = payload[0].payload?.[dataKey] ?? null;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
</BaseBox>
);
};
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
stats: DockerContainerStats;
dataKey: string;
strokeColor: string;
gradientStartColor: string;
range: DateRange;
formatter: (value: number) => string;
labelKey: string;
}
export const HistogramDockerContainer = ({
title,
rightTitle,
stats,
dataKey,
strokeColor,
gradientStartColor,
range,
formatter,
labelKey,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon="null"
title={title}
rightTitle={rightTitle}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={stats.aggregate}>
<CartesianGrid
strokeWidth={1}
stroke={theme.palette.divider}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Fragment>
{createGradient({
id: gradientId,
startColor: gradientStartColor,
endColor: "transparent",
direction: "vertical",
})}
<Tooltip
content={(props) => (
<DockerHistogramToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
dataKey={dataKey}
valueFormatter={formatter}
labelKey={labelKey}
/>
)}
/>
<Area
dataKey={dataKey}
type="monotone"
stroke={strokeColor}
fill={`url(#${gradientId})`}
/>
</Fragment>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -12,7 +12,85 @@ import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor"; import type { HardwareCheckStats } from "@/Types/Monitor";
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
export const HistogramInfrastructure = ({ export const HistogramInfrastructure = ({
dateRange, dateRange,
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure"; export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary"; export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar"; export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+52 -78
View File
@@ -19,94 +19,68 @@ import {
Users, Users,
Tag, Tag,
Waypoints, Waypoints,
Container,
} from "lucide-react"; } from "lucide-react";
export const getMenu = (t: Function) => { export const getMenu = (t: Function) => {
return [ return [
{ {
group: t("components.sidebar.menu.groups.monitoring"), name: t("components.sidebar.menu.uptime"),
items: [ path: "uptime",
{ icon: <Icon icon={Globe} />,
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
name: t("components.sidebar.menu.docker"),
path: "docker",
icon: <Icon icon={Container} />,
},
{
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
],
}, },
{ {
group: t("components.sidebar.menu.groups.alerting"), name: t("components.sidebar.menu.pagespeed"),
items: [ path: "pagespeed",
{ icon: <Icon icon={Gauge} />,
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
],
}, },
{ {
group: t("components.sidebar.menu.groups.reporting"), name: t("components.sidebar.menu.infrastructure"),
items: [ path: "infrastructure",
{ icon: <Icon icon={Link} />,
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={ScrollText} />,
},
],
}, },
{ {
group: t("components.sidebar.menu.groups.configuration"), name: t("components.sidebar.menu.notifications"),
items: [ path: "notifications",
{ icon: <Icon icon={Bell} />,
name: t("components.sidebar.menu.tags"), },
path: "tags", {
icon: <Icon icon={Tag} />, name: t("components.sidebar.menu.tags"),
}, path: "tags",
{ icon: <Icon icon={Tag} />,
name: t("components.sidebar.menu.proxies"), },
path: "proxies", {
icon: <Icon icon={Waypoints} />, name: t("components.sidebar.menu.proxies"),
}, path: "proxies",
{ icon: <Icon icon={Waypoints} />,
name: t("components.sidebar.menu.settings"), },
path: "settings", {
icon: <Icon icon={Settings} />, name: t("components.sidebar.menu.checks"),
}, path: "checks",
], icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={ScrollText} />,
},
{
name: t("components.sidebar.menu.settings"),
icon: <Icon icon={Settings} />,
path: "settings",
}, },
]; ];
}; };
+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
+13 -51
View File
@@ -1,12 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import Backdrop from "@mui/material/Backdrop"; import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List"; import List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useSidebar } from "@/Hooks/useSidebar.js"; import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo"; import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu"; import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -32,7 +29,7 @@ export const Sidebar = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { width, transition, transitionTiming, collapsed } = useSidebar(); const { width, transition, collapsed } = useSidebar();
const theme = useTheme(); const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t); const menu = getMenu(t);
@@ -73,11 +70,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
@@ -92,52 +89,17 @@ export const Sidebar = () => {
pt={theme.spacing(LAYOUT.MD)} pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)} pb={theme.spacing(LAYOUT.MD)}
/> />
{menu.map((group) => ( {menu.map((item) => {
<Box key={group.group}> const selected = location.pathname.startsWith(`/${item.path}`);
<Box return (
position="relative" <NavItem
overflow="hidden" key={item.path}
> item={item}
<Typography selected={selected}
variant="eyebrow" onClick={() => handleNavClick(item.path)}
component="div" />
color={theme.palette.text.disabled} );
fontSize={typographyLevels.s} })}
paddingLeft={theme.spacing(5)}
paddingTop={theme.spacing(LAYOUT.SM)}
paddingBottom={theme.spacing(LAYOUT.XXS)}
whiteSpace="nowrap"
sx={{
opacity: collapsed ? 0 : 1,
transition: `opacity ${transitionTiming}`,
}}
>
{group.group}
</Typography>
<Divider
sx={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
opacity: collapsed ? 1 : 0,
transition: `opacity ${transitionTiming}`,
}}
/>
</Box>
{group.items.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</Box>
))}
</List> </List>
<StarPrompt /> <StarPrompt />
+1 -16
View File
@@ -2,14 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark"; type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line"; type ChartType = "histogram" | "line";
type TableName = type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed"
| "docker";
interface TableState { interface TableState {
rowsPerPage: number; rowsPerPage: number;
@@ -21,11 +14,9 @@ interface SidebarState {
interface UIState { interface UIState {
monitors: TableState; monitors: TableState;
pagespeed: TableState;
team: TableState; team: TableState;
maintenance: TableState; maintenance: TableState;
infrastructure: TableState; infrastructure: TableState;
docker: TableState;
logs: TableState; logs: TableState;
sidebar: SidebarState; sidebar: SidebarState;
mode: ThemeMode; mode: ThemeMode;
@@ -46,9 +37,6 @@ const initialState: UIState = {
monitors: { monitors: {
rowsPerPage: 10, rowsPerPage: 10,
}, },
pagespeed: {
rowsPerPage: 10,
},
team: { team: {
rowsPerPage: 5, rowsPerPage: 5,
}, },
@@ -58,9 +46,6 @@ const initialState: UIState = {
infrastructure: { infrastructure: {
rowsPerPage: 5, rowsPerPage: 5,
}, },
docker: {
rowsPerPage: 5,
},
logs: { logs: {
rowsPerPage: 15, rowsPerPage: 15,
}, },
+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 };
}, []);
}; };
-7
View File
@@ -77,13 +77,6 @@ export const getMonitorDefaults = (
...base, ...base,
type: "docker", type: "docker",
url: data?.url || "", url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
// The key is write-only; the API never returns it.
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
}; };
break; break;
case "game": case "game":
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
+1 -3
View File
@@ -6,8 +6,7 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64; const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes // Transition timing for sidebar width changes
const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)"; const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
export const useSidebar = () => { export const useSidebar = () => {
const collapsed = useSelector( const collapsed = useSelector(
@@ -20,7 +19,6 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR, expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR, width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION, transition: SIDEBAR_TRANSITION,
transitionTiming: SIDEBAR_TRANSITION_TIMING,
}; };
}; };
@@ -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")}
+1 -2
View File
@@ -17,14 +17,13 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi"; import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check"; import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
import type { DateRange } from "@/Types/Query";
const Checks = () => { const Checks = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>(); const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0"); const [selectedMonitor, setSelectedMonitor] = useState<string>(monitorId || "0");
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [statusFilter, setStatusFilter] = useState<string>("down"); const [statusFilter, setStatusFilter] = useState<string>("down");
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10); const [rowsPerPage, setRowsPerPage] = useState<number>(10);
+3 -91
View File
@@ -50,7 +50,6 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import { import {
stepFieldsFor, stepFieldsFor,
monitorStepCount, monitorStepCount,
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";
@@ -67,14 +66,6 @@ const httpMethodOptions = HttpMethods.map((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 GeneralSettingsConfig { interface GeneralSettingsConfig {
urlLabel: string; urlLabel: string;
urlPlaceholder: string; urlPlaceholder: string;
@@ -127,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,
@@ -264,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]);
@@ -274,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
@@ -309,7 +297,6 @@ 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 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;
@@ -344,8 +331,6 @@ const CreateMonitorPage = () => {
[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;
@@ -388,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");
} }
@@ -701,64 +684,6 @@ 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")}
@@ -898,19 +823,6 @@ const CreateMonitorPage = () => {
/> />
)} )}
{showStep(1) && watchedType === "docker" && (
<ConfigBox
title={t("pages.createMonitor.form.dockerLogs.title")}
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
rightContent={
<FormSwitchField
name="dockerLogsEnabled"
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
/>
}
/>
)}
{showStep(2) && {showStep(2) &&
(watchedType === "http" || (watchedType === "http" ||
watchedType === "grpc" || watchedType === "grpc" ||
@@ -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>
);
};
@@ -1,54 +0,0 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
// Types
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
container,
}: {
container: DockerContainerInfo | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
if (!container) return null;
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const palette = getDockerPalette(state);
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("common.labels.state")}
subtitle={state}
palette={palette}
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={String(restartCount ?? 0)}
/>
<StatBox
title={t("common.labels.health")}
subtitle={health}
/>
</Stack>
);
};
@@ -1,271 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseBox, EmptyState } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
// Types
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
import type { DockerLog } from "@/Types/Check";
import type { ApiResponse } from "@/Hooks/UseApi";
// Hooks
import {
useState,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useCallback,
} from "react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
// Utils
import { get } from "@/Utils/ApiClient";
import { LAYOUT } from "@/Utils/Theme/constants";
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
interface TabLogsProps {
monitorId?: string;
containerName?: string;
enabled: boolean;
}
const SCROLL_PIN_THRESHOLD_PX = 8;
const TAIL_POLL_MS = 10_000;
const SCROLLBAR_SIZE = 14;
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",
scrollbarWidth: "auto",
scrollbarColor: "#11715b rgba(0,0,0,0.15)",
"&::-webkit-scrollbar": {
display: "block",
width: `${SCROLLBAR_SIZE}px`,
height: `${SCROLLBAR_SIZE}px`,
},
"&::-webkit-scrollbar-track": {
background: "rgba(0,0,0,0.15)",
borderRadius: "2px",
},
"&::-webkit-scrollbar-thumb": {
background: "#11715b",
borderRadius: "2px",
},
"&::-webkit-scrollbar-button": { display: "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>
);
};
@@ -1,126 +0,0 @@
// Components
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
// Hooks
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
// Types
import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatPercentage } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
import type { DateRange } from "@/Types/Query";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
title: t("common.charts.labels.cpuUsage"),
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
formatter: formatPercentage,
labelKey: "common.charts.histogram.avgCpuUsage",
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
formatter: prettyBytes,
labelKey: "common.charts.histogram.avgMemoryUsage",
},
];
export const TabOverview = ({
stats,
range,
}: {
stats: DockerContainerStats;
range: DateRange;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
<Grid
container
spacing={LAYOUT.MD}
>
{getChartConfigs(theme, t, stats).map((config) => {
return (
<Grid
key={config.key}
size={isSmall ? 12 : 6}
>
<HistogramDockerContainer
title={config.title}
rightTitle={config.rightTitle}
stats={stats}
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
range={range}
formatter={config.formatter}
labelKey={config.labelKey}
/>
</Grid>
);
})}
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Ports
</Typography>
<Stack>
{dedupedPorts?.map((port) => {
return (
<Typography
key={`${port.publicPort}/${port.privatePort}`}
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
<Grid size={isSmall ? 12 : 6}>
<BaseBox
padding={LAYOUT.MD}
minHeight={200}
>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
textTransform={"uppercase"}
>
Volumes
</Typography>
<Stack>
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
{`${getDockerMountLabel(mount)} -> ${mount.destination}`}
</Typography>
);
})}
</Stack>
</BaseBox>
</Grid>
</Grid>
);
};
@@ -1,89 +0,0 @@
// Components
import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
// Hooks
import { useParams } from "react-router-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { DockerContainerResponse } from "@/Types/Monitor";
import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
import type { DateRange } from "@/Types/Query";
import { HeaderTimeRange } from "@/Components/common";
interface DockerContainerDetailsProps {
open?: "overview" | "logs";
}
const TAB_MAP = {
overview: 0,
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
monitorId: string;
containerName: string;
}>();
const { t } = useTranslation();
const containerDetailsUrl = monitorId
? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
: null;
const { data: containerData } = useGet<DockerContainerResponse>(
containerDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = containerData?.monitor;
if (!monitor) return null;
const stats = containerData.stats;
const container = stats?.latest?.container;
if (!container) return null;
const { state, name, image } = container;
return (
<BasePage headerKey="docker.container">
<DockerStateLabel
state={state}
name={name}
image={image}
/>
<DockerContainerStatusBoxes container={container} />
<HeaderTimeRange
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
>
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && (
<TabOverview
stats={stats}
range={dateRange}
/>
)}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
containerName={containerName}
enabled={monitor.dockerLogsEnabled ?? false}
/>
)}
</BasePage>
);
};
export default DockerDetailsPage;
@@ -1,123 +0,0 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { DockerStateLabel, Table } from "@/Components/design-elements";
// Hooks
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
// Types
import type { Header } from "@/Components/design-elements/Table";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatPercentage } from "@/Utils/FormatUtils";
interface DockerContainersTableProps {
monitorId: string | undefined;
containers: DockerContainerInfo[];
onRowClick?: (container: DockerContainerInfo) => void;
}
export const DockerContainersTable = ({
monitorId,
containers,
}: DockerContainersTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const getHeaders = () => {
// const renderSortIcon = (isActive: boolean) => (
// <Box
// width={16}
// display="inline-flex"
// justifyContent="center"
// >
// {isActive ? (
// sortOrder === "asc" ? (
// <ArrowUp size={16} />
// ) : (
// <ArrowDown size={16} />
// )
// ) : null}
// </Box>
// );
const headers: Header<DockerContainerInfo>[] = [
{
id: "container",
content: t("pages.docker.host.table.headers.container"),
render: (row) => {
return (
<DockerStateLabel
state={row.state}
name={row.name}
image={row.image}
/>
);
},
},
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
return <Typography>{row.state}</Typography>;
},
},
{
id: "health",
content: t("common.labels.health"),
render: (row) => {
return <Typography>{row.health}</Typography>;
},
},
{
id: "cpu",
content: t("pages.docker.host.table.headers.cpu"),
render: (row) => {
return <Typography>{formatPercentage(row.cpuPct ?? 0)}</Typography>;
},
},
{
id: "memory",
content: t("pages.docker.host.table.headers.memory"),
render: (row) => {
return <Typography>{formatPercentage(row.memoryPct ?? 0)}</Typography>;
},
},
{
id: "restarts",
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={containers}
onRowClick={(row) => {
navigate(`/docker/host/${monitorId}/container/${row.name}`);
}}
// getRowSx={(row) => ({
// backgroundColor: isRowSelected(row.id)
// ? theme.palette.action.selected
// : "inherit",
// })}
/>
</Box>
);
};
@@ -1,47 +0,0 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
// Types
import type { DockerStats } from "@/Types/Monitor";
// Hooks
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
// Utils
export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
const { t } = useTranslation();
const theme = useTheme();
if (!stats) return null;
const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
return (
<Stack
direction="row"
gap={theme.spacing(8)}
flexWrap={"wrap"}
>
<StatBox
title={t("pages.docker.host.statBoxes.containers")}
subtitle={String(total)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.running")}
subtitle={String(running)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.stopped")}
subtitle={String(stopped)}
/>
<StatBox
title={t("pages.docker.host.statBoxes.unhealthy")}
subtitle={String(unhealthy)}
/>
</Stack>
);
};
@@ -1,55 +0,0 @@
import { BasePage } from "@/Components/design-elements";
import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
// Types
import type { DockerDetailsResponse } from "@/Types/Monitor";
// Hooks
import { useGet } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useParams } from "react-router-dom";
import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
const DockerHostDetailsPage = () => {
const { monitorId } = useParams<{ monitorId: string }>();
const isAdmin = useIsAdmin();
// The containers list renders the latest check only, so no date range is
// offered; the endpoint's dateRange param only shapes the aggregate buckets.
const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
const { data: monitorDetailsData, refetch: refetchMonitor } =
useGet<DockerDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
);
const monitor = monitorDetailsData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
const stats = monitorDetailsData?.stats;
const containers = stats?.latest?.containers ?? [];
return (
<BasePage>
<HeaderMonitorControls
path="docker"
monitor={monitor}
isAdmin={isAdmin}
refetch={refetchMonitor}
/>
<MonitorStatBoxes
monitor={monitor}
monitorStats={monitorStats}
/>
<DockerStatusBoxes stats={stats} />
<DockerContainersTable
monitorId={monitorId}
containers={containers}
/>
</BasePage>
);
};
export default DockerHostDetailsPage;
@@ -1,343 +0,0 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import {
Table,
Pagination,
StatusLabel,
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs";
const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
const recentChecks = monitor.recentChecks ?? [];
return recentChecks[recentChecks.length - 1]?.containerSummary;
};
interface DockerMonitorsTableProps {
monitors: Monitor[];
tags: Tag[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const DockerMonitorsTable = ({
monitors,
tags,
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
selectedRows = [],
onSelectionChange,
}: DockerMonitorsTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const { post } = usePost<Record<string, never>, Monitor>();
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 2,
label: t("pages.common.monitors.actions.details"),
action: () => {
navigate(`${monitor.id}`);
},
},
{
id: 3,
label: t("pages.common.monitors.actions.incidents"),
action: () => {
navigate(`/incidents?monitorId=${monitor.id}`);
},
},
{
id: 4,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/docker/configure/${monitor.id}`);
},
},
{
id: 6,
label:
monitor.isActive === false
? t("common.buttons.resume")
: t("common.buttons.pause"),
action: async () => {
await post(`/monitors/pause/${monitor.id}`, {});
refetch();
},
closeMenu: true,
},
{
id: 7,
label: (
<Typography color={theme.palette.error.main}>
{t("common.buttons.delete")}
</Typography>
),
action: () => {
setSelectedMonitor(monitor);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const renderSortIcon = (isActive: boolean) => (
<Box
width={16}
display="inline-flex"
justifyContent="center"
>
{isActive ? (
sortOrder === "asc" ? (
<ArrowUp size={16} />
) : (
<ArrowDown size={16} />
)
) : null}
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("pages.docker.table.headers.host"),
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "name")}
sx={{ cursor: "pointer" }}
>
{t("pages.docker.table.headers.host")}
{renderSortIcon(sortField === "name")}
</Typography>
),
render: (row) => {
return (
<Stack>
<Typography variant="body2">{row.name}</Typography>
<Typography
variant="caption"
color={theme.palette.text.secondary}
>
{row.url}
</Typography>
</Stack>
);
},
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
),
render: (row) => {
return <StatusLabel status={row.status} />;
},
},
{
id: "containers",
content: t("pages.docker.table.headers.containers"),
render: (row) => {
const summary = summaryOf(row);
return (
<Typography variant="body2">
{summary
? t("pages.docker.table.containersRunning", {
running: summary.running,
total: summary.total,
})
: "—"}
</Typography>
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
render: (row) => {
if (row.tags.length === 0) return "-";
return (
<Stack
justifyContent={"center"}
direction={isSmall ? "column" : "row"}
gap={theme.spacing(SPACING.LG)}
>
{row.tags.map((tagId) => {
const fullTag = tags.find((t) => t.id === tagId);
if (!fullTag) return null;
return (
<ColoredLabel
key={fullTag.id}
text={fullTag.name}
color={fullTag.color}
/>
);
})}
</Stack>
);
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/docker/host/${row.id}`);
}}
isRowSelected={(row) => isRowSelected(row.id)}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
itemsOnPage={monitors.length}
/>
</Box>
);
};
@@ -1,44 +0,0 @@
import { MonitorListPage } from "@/Components/monitors";
import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
const DockerMonitorsPage = () => {
const c = useMonitorListController({
types: ["docker"],
checksLimit: 25,
refreshInterval: 5000,
rowsPerPageTable: "docker",
rowsPerPageDefault: 10,
});
return (
<MonitorListPage
headerKey="docker"
page="docker"
actionLink="/docker/create"
controller={c}
bulkActions
showTypeFilter={false}
>
<DockerMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
/>
</MonitorListPage>
);
};
export default DockerMonitorsPage;
@@ -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}
+1 -2
View File
@@ -20,7 +20,6 @@ import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident"; import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { DateRange } from "@/Types/Query";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
const IncidentsPage = () => { const IncidentsPage = () => {
@@ -31,7 +30,7 @@ const IncidentsPage = () => {
// Filter state // Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10); const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0); const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => { const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [ return [
{ {
title: t("common.charts.labels.memoryUsage"), title: t("pages.infrastructure.charts.labels.memory"),
type: "memory", type: "memory",
dataKeys: ["avgMemoryUsage"], dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main, strokeColor: theme.palette.primary.main,
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
idx: null, idx: null,
}, },
{ {
title: t("common.charts.labels.cpuUsage"), title: t("pages.infrastructure.charts.labels.cpu"),
type: "cpu", type: "cpu",
dataKeys: ["avgCpuUsage"], dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main, strokeColor: theme.palette.success.main,
@@ -63,42 +63,19 @@ const getChartConfigs = (
return configs; return configs;
}; };
// Loopback / bridge names Capture reports when it can only observe the // Loopback / bridge names Capture sees when it can only observe the container's
// container's own network namespace. // own network namespace. When ALL reported interfaces match this set we assume
// the "no real interfaces visible" case and surface the notice.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]); const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
// Below this, an interface is carrying no traffic worth charting. Real host
// NICs sit orders of magnitude above it; a container's veth sits at or near
// zero.
const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
checks.every((check) => {
const iface = check.net?.find((candidate) => candidate.name === name);
if (!iface) {
return true;
}
return (
(iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
(iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
);
});
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => { const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set<string>(); const named = new Set<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name))); checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) { if (named.size === 0) return false;
return false; for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
} }
return true;
const names = [...named];
if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
return true;
}
if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
return false;
}
return names.every((name) => interfaceIsIdle(checks, name));
}; };
export const InfraNetworkCharts = ({ export const InfraNetworkCharts = ({
@@ -8,8 +8,6 @@ import { useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import type { CheckSnapshot } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
const GAUGE_MAX_WIDTH = 260;
export const InfraDetailsGauges = ({ export const InfraDetailsGauges = ({
snapshot, snapshot,
}: { }: {
@@ -28,42 +26,37 @@ export const InfraDetailsGauges = ({
direction={isSmall ? "column" : "row"} direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)} spacing={theme.spacing(8)}
alignItems={"stretch"} alignItems={"stretch"}
flexWrap={"wrap"}
useFlexGap
> >
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.memory.title")} title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={GAUGE_MAX_WIDTH} maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100} progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")} upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)} upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")} lowerLabel={t("pages.infrastructure.gauges.memory.lowerLabel")}
lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)} lowerValue={prettyBytes(snapshot?.memory?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/> />
<DetailGauge <DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")} title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={GAUGE_MAX_WIDTH} maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100} progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")} upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)} upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")} lowerLabel={t("pages.infrastructure.gauges.cpu.lowerLabel")}
lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)} lowerValue={getFrequency(snapshot?.cpu?.frequency || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/> />
{snapshot?.disk?.map((disk, idx) => { {snapshot?.disk?.map((disk, idx) => {
return ( return (
<DetailGauge <DetailGauge
key={disk?.device || `disk-${idx}`} key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`} // title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })} title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={GAUGE_MAX_WIDTH} maxWidth={260}
progress={(disk.usage_percent || 0) * 100} progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")} upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)} upperValue={prettyBytes(disk?.used_bytes || 0)}
lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")} lowerLabel={t("pages.infrastructure.gauges.disk.lowerLabel")}
lowerValue={prettyBytes(disk?.total_bytes || 0)} lowerValue={prettyBytes(disk?.total_bytes || 0)}
flexBasis={isSmall ? "auto" : GAUGE_MAX_WIDTH}
/> />
); );
})} })}
@@ -10,7 +10,6 @@ import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor"; import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DateRange } from "@/Types/Query";
const InfrastructureDetails = () => { const InfrastructureDetails = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +17,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>(); const { monitorId } = useParams<{ monitorId: string }>();
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [selectedTab, setSelectedTab] = useState<number>(0); const [selectedTab, setSelectedTab] = useState<number>(0);
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
@@ -331,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"
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors"; import Stack from "@mui/material/Stack";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController"; import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => { const InfrastructureMonitors = () => {
const c = useMonitorListController({ const { t } = useTranslation();
types: ["hardware"], const theme = useTheme();
checksLimit: 1, const isSmall = useMediaQuery(theme.breakpoints.down("md"));
refreshInterval: 5000, const isAdmin = useIsAdmin();
rowsPerPageTable: "infrastructure", const dispatch = useDispatch();
rowsPerPageDefault: 5,
}); // Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="infrastructure" headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure" page="infrastructure"
actionLink="/infrastructure/create" actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
> >
<InfraMonitorsTable <HeaderCreate
monitors={c.monitors || []} path="/infrastructure/create"
tags={c.tags || []} isLoading={isLoading}
refetch={c.refetch} isAdmin={isAdmin}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
/> />
</MonitorListPage> <HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
+1 -24
View File
@@ -1,8 +1,7 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button } from "@/Components/inputs";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
@@ -16,7 +15,6 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr"; import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { useEffect } from "react"; import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -43,7 +41,6 @@ const CreateMaintenanceWindowPage = () => {
); );
const { data: monitors } = useGet<Monitor[]>("/monitors/team"); const { data: monitors } = useGet<Monitor[]>("/monitors/team");
const { data: tags } = useGet<Tag[]>("/tags/team");
const { post, loading: isPosting } = usePost(); const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch(); const { patch, loading: isPatching } = usePatch();
@@ -206,26 +203,6 @@ const CreateMaintenanceWindowPage = () => {
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)} )}
options={monitors ?? []} options={monitors ?? []}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>{monitor.name}</Typography>
{monitor.tags.map((tagId) => {
const tag = tags?.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
) : null;
})}
</Stack>
)}
/> />
} }
/> />
+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")}
+1 -2
View File
@@ -15,13 +15,12 @@ import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import type { DateRange } from "@/Types/Query";
const PageSpeedDetails = () => { const PageSpeedDetails = () => {
const { monitorId } = useParams(); const { monitorId } = useParams();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const theme = useTheme(); const theme = useTheme();
const [dateRange, setDateRange] = useState<DateRange>("day"); const [dateRange, setDateRange] = useState<string>("day");
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
if (!monitorId) { if (!monitorId) {
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements"; import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi"; import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings"; import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors"; import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
const PageSpeedMonitorsPage = () => { const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const { const {
data: settingsData, data: settingsData,
isLoading: settingsIsLoading, isLoading: settingsIsLoading,
error: settingsError, error: settingsError,
} = useGet<AppSettingsResponse>("/settings"); } = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({ const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
types: ["pagespeed"], const isDialogOpen = Boolean(selectedMonitor);
checksLimit: 25, const [sortField, setSortField] = useState<string>("name");
refreshInterval: 30000, const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
rowsPerPageTable: "pagespeed", const [page, setPage] = useState(0);
rowsPerPageDefault: 10, const [rowsPerPage, setRowsPerPage] = useState(10);
initialSortField: "name", const [selectedTags, setSelectedTags] = useState<string[]>([]);
}); const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet; const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="pageSpeed" headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed" page="pageSpeed"
actionLink="/pagespeed/create" actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined} priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
> >
<PageSpeedMonitorsTable <HeaderCreate
monitors={c.monitors || []} path="/pagespeed/create"
tags={c.tags || []} isLoading={isLoading}
refetch={c.refetch} isAdmin={isAdmin}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
/> />
</MonitorListPage> <HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
File diff suppressed because it is too large. Load diff
@@ -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")}
@@ -211,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>
@@ -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 -2
View File
@@ -31,7 +31,6 @@ import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next"; import { t } from "i18next";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
import type { DateRange } from "@/Types/Query";
const certificateDateFormat = "MMM D, YYYY h A"; const certificateDateFormat = "MMM D, YYYY h A";
@@ -54,7 +53,7 @@ const UptimeDetailsPage = () => {
const [rowsPerPage, setRowsPerPage] = useState<number>(5); const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0); const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5); const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<DateRange>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA"); const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
@@ -350,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"
+280 -29
View File
@@ -1,43 +1,294 @@
import { MonitorListPage } from "@/Components/monitors"; import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { TextField, Dialog, Button } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
import Typography from "@mui/material/Typography";
import { useMonitorListController } from "@/Hooks/useMonitorListController"; import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import { SPACING } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => { const UptimeMonitorsPage = () => {
const monitorListController = useMonitorListController({ const { t } = useTranslation();
types: "selectable", const theme = useTheme();
checksLimit: 25, const isSmall = useMediaQuery(theme.breakpoints.down("md"));
refreshInterval: 5000, const dispatch = useDispatch();
rowsPerPageTable: "monitors", const isAdmin = useIsAdmin();
rowsPerPageDefault: 10, // Redux state
}); const rowsPerPage = useSelector(
(state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
);
// Local state
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const {
monitors: monitorsWithChecks,
summary,
count,
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
// Handlers
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const isLoading = monitorsWithChecksLoading;
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return ( return (
<MonitorListPage <MonitorBasePageWithStates
headerKey="uptime" headerKey="uptime"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="uptime" page="uptime"
actionLink="/uptime/create" actionLink="/uptime/create"
controller={monitorListController}
bulkActions
> >
<MonitorTable <HeaderCreate
monitors={monitorListController.monitors || []} path="/uptime/create"
tags={monitorListController.tags || []} isLoading={isLoading}
refetch={monitorListController.refetch} isAdmin={isAdmin}
setSelectedMonitor={monitorListController.setSelectedMonitor}
count={monitorListController.count || 0}
page={monitorListController.page}
setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder}
setSortOrder={monitorListController.setSortOrder}
setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
/> />
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
selectedTypes={selectedTypes}
setSelectedTypes={setSelectedTypes}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<MonitorTable
monitors={monitorsWithChecks || []}
tags={tags || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
setRowsPerPage={(rowsPerPage: number) => {
dispatch(
setRowsPerPage({
value: rowsPerPage,
table: "monitors",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
); );
}; };
-25
View File
@@ -20,11 +20,6 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
import Infrastructure from "@/Pages/Infrastructure/Monitors"; import Infrastructure from "@/Pages/Infrastructure/Monitors";
import InfrastructureDetails from "@/Pages/Infrastructure/Details"; import InfrastructureDetails from "@/Pages/Infrastructure/Details";
// Docker
import Docker from "@/Pages/Docker/Monitors";
import DockerHostDetails from "@/Pages/Docker/HostDetails";
import DockerDetails from "@/Pages/Docker/ContainerDetails";
// Checks // Checks
import Checks from "@/Pages/Checks"; import Checks from "@/Pages/Checks";
@@ -144,26 +139,6 @@ const Routes = () => {
path="infrastructure/:monitorId" path="infrastructure/:monitorId"
element={<InfrastructureDetails />} element={<InfrastructureDetails />}
/> />
<Route
path="docker"
element={<Docker />}
/>
<Route
path="docker/create"
element={<CreateMonitor />}
/>
<Route
path="/docker/configure/:monitorId"
element={<CreateMonitor />}
/>
<Route
path="docker/host/:monitorId/container/:containerName"
element={<DockerDetails />}
/>
<Route
path="docker/host/:monitorId"
element={<DockerHostDetails />}
/>
<Route <Route
path="checks/:monitorId?" path="checks/:monitorId?"
element={<Checks />} element={<Checks />}
-86
View File
@@ -119,90 +119,6 @@ export interface CheckTimings {
}; };
} }
// Mirrors DockerContainerSummary in server/src/types/network.ts.
export interface DockerContainerSummary {
total: number;
running: number;
stopped: number;
unhealthy: number;
}
// Mirrors DockerContainerStates in server/src/types/network.ts.
export const DockerContainerStates = [
"created",
"running",
"paused",
"restarting",
"removing",
"exited",
"dead",
] as const;
export type DockerContainerState = (typeof DockerContainerStates)[number];
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
export interface DockerContainerPort {
privatePort: number;
protocol: DockerPortProtocol;
publicPort?: number;
hostIp?: string;
}
export interface DockerContainerMount {
type: string;
name?: string;
source: string;
destination: string;
mode: string;
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 {
id: string;
name: string;
image: string;
state: DockerContainerState;
status: string;
health: DockerHealthStatus;
cpuPct?: number;
memoryUsedBytes?: number;
memoryLimitBytes?: number;
memoryPct?: number;
restartCount?: number;
startedAt?: string;
ports?: DockerContainerPort[];
mounts?: DockerContainerMount[];
}
export interface Check { export interface Check {
id: string; id: string;
metadata: CheckMetadata; metadata: CheckMetadata;
@@ -217,7 +133,6 @@ export interface Check {
host?: CheckHostInfo; host?: CheckHostInfo;
errors?: CheckErrorInfo[]; errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo; capture?: CheckCaptureInfo;
containerSummary?: DockerContainerSummary;
net?: CheckNetworkInterfaceInfo[]; net?: CheckNetworkInterfaceInfo[];
accessibility?: number; accessibility?: number;
bestPractices?: number; bestPractices?: number;
@@ -372,7 +287,6 @@ export type CheckSnapshot = Pick<
memory?: SnapshotMemoryInfo; memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[]; disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo; host?: SnapshotHostInfo;
containerSummary?: DockerContainerSummary;
}; };
export interface HasResponseTime { export interface HasResponseTime {
responseTime: number; responseTime: number;
+1 -63
View File
@@ -3,9 +3,6 @@ import type {
CheckSnapshot, CheckSnapshot,
GroupedUptimeCheck, GroupedUptimeCheck,
DailyCheckBucket, DailyCheckBucket,
DockerContainerInfo,
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";
@@ -33,6 +30,7 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const SelectableMonitorTypes = [ export const SelectableMonitorTypes = [
"http", "http",
"ping", "ping",
"docker",
"port", "port",
"game", "game",
"grpc", "grpc",
@@ -159,10 +157,6 @@ export interface Monitor {
geoCheckEnabled?: boolean; geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[]; geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number; geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string; dnsServer?: string;
dnsRecordType?: DnsRecordType; dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
@@ -275,62 +269,6 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null; monitorStats: MonitorStats | null;
} }
export interface DockerStatsBucket {
_id: string;
avgResponseTime: number | null;
upCount: number;
totalCount: number;
avgRunning: number | null;
avgTotal: number | null;
avgUnhealthy: number | null;
}
export interface DockerStats {
aggregateData: {
totalChecks: number;
};
upChecks: {
totalChecks: number;
};
aggregate: DockerStatsBucket[];
latest: {
containers: DockerContainerInfo[];
summary?: DockerContainerSummary;
checkedAt: string;
} | null;
}
export interface DockerDetailsResponse {
monitor: Monitor;
stats: DockerStats;
monitorStats: MonitorStats | null;
}
export interface DockerContainerStatsBucket {
_id: string;
avgCpuPct: number | null;
avgMemoryUsedBytes: number | null;
avgMemoryPct: number | null;
minRestartCount: number | null;
maxRestartCount: number | null;
}
export interface DockerContainerStats {
aggregate: DockerContainerStatsBucket[];
restartsInRange: number;
latest: {
container: DockerContainerInfo;
checkedAt: string;
} | null;
}
export interface DockerContainerResponse {
monitor: Monitor;
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;
-2
View File
@@ -1,2 +0,0 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
+1 -91
View File
@@ -1,14 +1,6 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
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 {
DockerLogStreams,
type DockerContainerMount,
type DockerContainerPort,
type DockerContainerState,
type DockerLog,
type DockerLogLine,
} from "@/Types/Check";
export const getMonitorPath = (type: MonitorType): string => { export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = { const pathMap: Record<MonitorType, string> = {
@@ -20,7 +12,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime", websocket: "uptime",
dns: "uptime", dns: "uptime",
unknown: "uptime", unknown: "uptime",
docker: "docker", docker: "uptime",
hardware: "infrastructure", hardware: "infrastructure",
pagespeed: "pagespeed", pagespeed: "pagespeed",
}; };
@@ -40,14 +32,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",
@@ -57,19 +41,6 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
return paletteMap[value]; return paletteMap[value];
}; };
export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
const paletteMap: Record<DockerContainerState, PaletteKey> = {
paused: "warning",
created: "warning",
running: "success",
restarting: "warning",
removing: "warning",
exited: "error",
dead: "error",
};
return paletteMap[state];
};
export const getStatusColor = (status: MonitorStatus, theme: any): string => { export const getStatusColor = (status: MonitorStatus, theme: any): string => {
if (status === "up") { if (status === "up") {
return theme.palette.success.light; return theme.palette.success.light;
@@ -126,64 +97,3 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
? `${strippedUrl.slice(0, maxLength)}…` ? `${strippedUrl.slice(0, maxLength)}…`
: strippedUrl; : strippedUrl;
}; };
export const dedupeDockerPorts = (
ports: DockerContainerPort[]
): DockerContainerPort[] => {
return ports.filter((port) => {
// Discard ipv6 wildcards
if (port.hostIp !== "::") return port;
const isDuplicate = ports.some((otherPort) => {
if (otherPort.hostIp !== "0.0.0.0") return false;
return (
otherPort.privatePort === port.privatePort &&
otherPort.protocol === port.protocol &&
otherPort.publicPort === port.publicPort
);
});
return !isDuplicate;
});
};
const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name);
export const getDockerMountLabel = (mount: DockerContainerMount) => {
if (mount.type === "volume" && mount.name)
return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name;
if (mount.source) return mount.source;
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))
);
});
};
-62
View File
@@ -1,5 +1,4 @@
import { alpha } from "@mui/material/styles"; import { alpha } from "@mui/material/styles";
import { HOVER } from "@/Utils/Theme/constants";
const typographyBase = 13; const typographyBase = 13;
@@ -18,20 +17,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",
@@ -54,34 +39,9 @@ export const lightPalette = {
primary: { primary: {
main: colors.brandGreen, main: colors.brandGreen,
}, },
action: {
rowHover: alpha(colors.gray900, HOVER.ROW),
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,
}, },
@@ -104,13 +64,6 @@ export const lightPalette = {
export const darkPalette = { export const darkPalette = {
primary: { primary: {
main: colors.brandGreen, main: colors.brandGreen,
light: colors.brandGreenLight,
},
action: {
rowHover: alpha("#FFFFFF", HOVER.ROW),
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,
@@ -119,21 +72,6 @@ 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,
}, },
+10 -41
View File
@@ -13,11 +13,6 @@ declare module "@mui/material/styles" {
eyebrow?: React.CSSProperties; eyebrow?: React.CSSProperties;
fontFamilyMonospace?: string; fontFamilyMonospace?: string;
} }
interface TypeAction {
rowHover: string;
controlHover: string;
selectedHover: string;
}
interface Palette { interface Palette {
sidebar: { accent: string }; sidebar: { accent: string };
rowStatus: { running: string; paused: string }; rowStatus: { running: string; paused: string };
@@ -58,7 +53,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 =
@@ -86,13 +81,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,
@@ -134,11 +127,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,
}, },
}), }),
@@ -192,38 +181,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,
}, },
}), }),
}, },
-4
View File
@@ -18,10 +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 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
CONTROL: 0.05, // Stronger overlay for controls nested inside a hoverable row
} as const; } as const;
+3 -49
View File
@@ -108,31 +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 DOCKER_TLS_URL and // Docker monitor schema
// DOCKER_SOCKET_URL in 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() ?? "");
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),
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
),
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 }),
}); });
// Game server monitor schema // Game server monitor schema
@@ -237,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({
@@ -247,30 +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" && 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;
} }
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "صفحات الحالة", "statusPages": "صفحات الحالة",
"maintenance": "الصيانة", "maintenance": "الصيانة",
"logs": "السجلات", "logs": "السجلات",
"settings": "الإعدادات", "settings": "الإعدادات"
"groups": {
"monitoring": "المراقبة",
"alerting": "التنبيهات",
"reporting": "التقارير",
"configuration": "الإعدادات العامة"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "الدعم", "support": "الدعم",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "عام",
"monitoring": "المراقبة",
"email": "البريد الإلكتروني",
"integrations": "التكاملات",
"data": "البيانات"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "المنطقة الزمنية للعرض", "title": "المنطقة الزمنية للعرض",
+1 -14
View File
@@ -189,13 +189,7 @@
"statusPages": "Pàgines d'estat", "statusPages": "Pàgines d'estat",
"maintenance": "Manteniment", "maintenance": "Manteniment",
"logs": "Registres", "logs": "Registres",
"settings": "Configuració", "settings": "Configuració"
"groups": {
"monitoring": "Monitoratge",
"alerting": "Alertes",
"reporting": "Informes",
"configuration": "Configuració"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Suport", "support": "Suport",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "General",
"monitoring": "Monitoratge",
"email": "Correu electrònic",
"integrations": "Integracions",
"data": "Dades"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Zona horària de visualització", "title": "Zona horària de visualització",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Stavové stránky", "statusPages": "Stavové stránky",
"maintenance": "Údržba", "maintenance": "Údržba",
"logs": "Záznamy", "logs": "Záznamy",
"settings": "Nastavení", "settings": "Nastavení"
"groups": {
"monitoring": "Monitorování",
"alerting": "Upozornění",
"reporting": "Přehledy",
"configuration": "Konfigurace"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Podpora", "support": "Podpora",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "Obecné",
"monitoring": "Monitorování",
"email": "E-mail",
"integrations": "Integrace",
"data": "Data"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Zobrazované časové pásmo", "title": "Zobrazované časové pásmo",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Statusseite", "statusPages": "Statusseite",
"maintenance": "Wartung", "maintenance": "Wartung",
"logs": "Protokolle", "logs": "Protokolle",
"settings": "Einstellungen", "settings": "Einstellungen"
"groups": {
"monitoring": "Überwachung",
"alerting": "Benachrichtigungen",
"reporting": "Berichte",
"configuration": "Konfiguration"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Support", "support": "Support",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "Allgemein",
"monitoring": "Überwachung",
"email": "E-Mail",
"integrations": "Integrationen",
"data": "Daten"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Anzeigesprache Zeitzone", "title": "Anzeigesprache Zeitzone",
+14 -143
View File
@@ -66,11 +66,9 @@
"charts": { "charts": {
"labels": { "labels": {
"averageResponseTime": "Average response time", "averageResponseTime": "Average response time",
"cpuUsage": "CPU usage",
"downtime": "Downtime", "downtime": "Downtime",
"high": "High", "high": "High",
"low": "Low", "low": "Low",
"memoryUsage": "Memory usage",
"responseTime": "Response time", "responseTime": "Response time",
"timingPhases": "Timing phases", "timingPhases": "Timing phases",
"totalResponseTime": "Total response time", "totalResponseTime": "Total response time",
@@ -80,9 +78,7 @@
"avg": "Avg: {{value}}", "avg": "Avg: {{value}}",
"max": "Max: {{value}}", "max": "Max: {{value}}",
"responseTime": "Response time: {{value}}", "responseTime": "Response time: {{value}}",
"total": "Total: {{value}}", "total": "Total: {{value}}"
"avgCpuUsage": "Average CPU usage: {{value}}",
"avgMemoryUsage": "Average memory usage: {{value}}"
}, },
"phases": { "phases": {
"dns": "DNS", "dns": "DNS",
@@ -106,11 +102,7 @@
"idle": "Idle", "idle": "Idle",
"na": "N/A", "na": "N/A",
"resolved": "Resolved", "resolved": "Resolved",
"responseTime": "Response time", "responseTime": "Response time"
"state": "State",
"uptime": "Uptime",
"health": "Health",
"restarts": "Restarts"
}, },
"form": { "form": {
"autocomplete": { "autocomplete": {
@@ -211,7 +203,6 @@
"uptime": "Uptime", "uptime": "Uptime",
"pagespeed": "Pagespeed", "pagespeed": "Pagespeed",
"infrastructure": "Infrastructure", "infrastructure": "Infrastructure",
"docker": "Docker",
"notifications": "Notifications", "notifications": "Notifications",
"tags": "Tags", "tags": "Tags",
"proxies": "Proxies", "proxies": "Proxies",
@@ -220,13 +211,7 @@
"statusPages": "Status pages", "statusPages": "Status pages",
"maintenance": "Maintenance", "maintenance": "Maintenance",
"logs": "Logs", "logs": "Logs",
"settings": "Settings", "settings": "Settings"
"groups": {
"monitoring": "Monitoring",
"alerting": "Alerting",
"reporting": "Reporting",
"configuration": "Configuration"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Support", "support": "Support",
@@ -634,9 +619,9 @@
}, },
"general": { "general": {
"option": { "option": {
"dockerHost": { "container": {
"label": "Docker host", "label": "Container name/ID",
"placeholder": "unix:///var/run/docker.sock" "placeholder": "my-app or abcd1234"
}, },
"host": { "host": {
"label": "Host", "label": "Host",
@@ -718,7 +703,7 @@
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. Every container on the host is monitored.", "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.", "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
@@ -758,7 +743,7 @@
}, },
"type": { "type": {
"description": "Select the type of check to perform", "description": "Select the type of check to perform",
"optionDockerDescription": "Monitor a Docker host and every container running on it.", "optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGameDescription": "Monitor if a specific game server is online.", "optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.", "optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
@@ -816,15 +801,6 @@
} }
} }
}, },
"dockerLogs": {
"title": "Container logs",
"description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.",
"option": {
"enabled": {
"label": "Collect container logs"
}
}
},
"stats": { "stats": {
"title": "Monitor history", "title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
@@ -834,29 +810,6 @@
"description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.", "description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.",
"confirm": "Yes, clear all stats" "confirm": "Yes, clear all stats"
} }
},
"dockerTls": {
"title": "TLS credentials",
"description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.",
"option": {
"ca": {
"label": "CA certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"cert": {
"label": "Client certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"key": {
"label": "Client key",
"placeholder": "-----BEGIN PRIVATE KEY-----",
"stored": "A key is stored. Leave blank to keep it."
},
"ignoreTls": {
"label": "Ignore TLS/SSL errors",
"description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them."
}
}
} }
} }
}, },
@@ -965,81 +918,15 @@
"description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history." "description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history."
} }
}, },
"docker": {
"header": {
"title": "Docker monitors",
"description": "Monitor your Docker hosts and every container running on them."
},
"fallback": {
"title": "No Docker monitors yet",
"description": "Add a Docker host to monitor its containers.",
"actionButton": "Create a monitor"
},
"table": {
"headers": {
"host": "Host",
"containers": "Containers"
},
"containersRunning": "{{running}} of {{total}} running"
},
"container": {
"header": {
"title": "Container details",
"description": "State, resource usage, and logs for this container."
},
"tabs": {
"overview": "Overview",
"logs": "Logs"
},
"logs": {
"recentLines": "{{count}} recent lines",
"loading": "Loading...",
"updatedSecondsAgo": "Updated {{count}}s ago",
"updatedMinutesAgo": "Updated {{count}}m ago",
"skipped": "Some log entries were skipped",
"loadOlder": "Load older logs",
"loadingOlder": "Loading...",
"noOlder": "Beginning of retained logs",
"loadError": "Could not load logs",
"loadOlderError": "Could not load older logs",
"empty": {
"title": "No logs collected yet",
"description": "Lines will appear here after the next check. Only output written since collection was turned on is stored."
},
"disabled": {
"title": "Log collection is off for this host",
"description": "Turn on container logs in the monitor settings to start collecting.",
"action": "Configure monitor"
},
"forbidden": {
"title": "You don't have access to container logs",
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
}
}
},
"host": {
"statBoxes": {
"containers": "Containers",
"running": "Running",
"stopped": "Stopped",
"unhealthy": "Unhealthy"
},
"table": {
"headers": {
"container": "Containers",
"cpu": "CPU",
"memory": "Memory"
}
}
}
},
"infrastructure": { "infrastructure": {
"charts": { "charts": {
"labels": { "labels": {
"cpu": "CPU usage",
"disk": "Disk usage", "disk": "Disk usage",
"memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received", "netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent", "netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.", "loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
"temp": "CPU temperature" "temp": "CPU temperature"
} }
}, },
@@ -1276,7 +1163,7 @@
}, },
"startTime": { "startTime": {
"title": "Start time", "title": "Start time",
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.", "description": "Set the start time and duration for your maintenance window. All values are in UTC",
"option": { "option": {
"duration": { "duration": {
"label": "Duration" "label": "Duration"
@@ -1358,7 +1245,6 @@
"teams": "Teams", "teams": "Teams",
"telegram": "Telegram", "telegram": "Telegram",
"pushover": "Pushover", "pushover": "Pushover",
"signalgrid": "Signalgrid",
"twilio": "Twilio", "twilio": "Twilio",
"ntfy": "ntfy" "ntfy": "ntfy"
} }
@@ -1379,14 +1265,6 @@
"optionUserKey": "User key", "optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
}, },
"signalgrid": {
"title": "Signalgrid configuration",
"description": "Configure Signalgrid to receive push notifications on your devices.",
"optionClientKey": "Client key",
"placeholderClientKey": "Your Signalgrid client key",
"optionChannel": "Channel",
"placeholderChannel": "Your Signalgrid channel"
},
"twilio": { "twilio": {
"title": "Twilio SMS configuration", "title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.", "description": "Configure Twilio to send SMS notifications to a phone number.",
@@ -1455,13 +1333,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "General",
"monitoring": "Monitoring",
"email": "Email",
"integrations": "Integrations",
"data": "Data"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Display timezone", "title": "Display timezone",
@@ -1519,7 +1390,7 @@
} }
}, },
"url": { "url": {
"title": "Monitor IP/URL on status page", "title": "Monitor IP/URL on Status Page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
"option": { "option": {
"showURL": { "showURL": {
@@ -1551,7 +1422,7 @@
} }
}, },
"thresholds": { "thresholds": {
"title": "Global thresholds", "title": "Global Thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.", "description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
"option": { "option": {
"cpu": { "cpu": {
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Páginas de estado", "statusPages": "Páginas de estado",
"maintenance": "Mantenimiento", "maintenance": "Mantenimiento",
"logs": "Registros", "logs": "Registros",
"settings": "Configuración", "settings": "Configuración"
"groups": {
"monitoring": "Monitorización",
"alerting": "Alertas",
"reporting": "Informes",
"configuration": "Configuración"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Soporte", "support": "Soporte",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "General",
"monitoring": "Monitorización",
"email": "Correo electrónico",
"integrations": "Integraciones",
"data": "Datos"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Zona horaria de visualización", "title": "Zona horaria de visualización",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Tilasivut", "statusPages": "Tilasivut",
"maintenance": "Huolto", "maintenance": "Huolto",
"logs": "Lokit", "logs": "Lokit",
"settings": "Asetukset", "settings": "Asetukset"
"groups": {
"monitoring": "Valvonta",
"alerting": "Hälytykset",
"reporting": "Raportointi",
"configuration": "Määritykset"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Tuki", "support": "Tuki",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "Yleiset",
"monitoring": "Valvonta",
"email": "Sähköposti",
"integrations": "Integraatiot",
"data": "Tiedot"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Näytettävä aikavyöhyke", "title": "Näytettävä aikavyöhyke",
+1 -14
View File
@@ -181,13 +181,7 @@
"statusPages": "Pages de statut", "statusPages": "Pages de statut",
"maintenance": "Maintenance", "maintenance": "Maintenance",
"logs": "Journaux", "logs": "Journaux",
"settings": "Paramètres", "settings": "Paramètres"
"groups": {
"monitoring": "Surveillance",
"alerting": "Alertes",
"reporting": "Rapports",
"configuration": "Configuration"
}
}, },
"bottomMenu": { "bottomMenu": {
"support": "Support", "support": "Support",
@@ -1147,13 +1141,6 @@
} }
}, },
"settings": { "settings": {
"tabs": {
"general": "Général",
"monitoring": "Surveillance",
"email": "E-mail",
"integrations": "Intégrations",
"data": "Données"
},
"form": { "form": {
"timezone": { "timezone": {
"title": "Fuseau horaire d'affichage", "title": "Fuseau horaire d'affichage",
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more