Compare commits
210
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5766a55a9 | ||
|
|
c5902b397a | ||
|
|
e52ec2d6ee | ||
|
|
a2c978527f | ||
|
|
224dc60309 | ||
|
|
d8e4c775d5 | ||
|
|
a07d0059b7 | ||
|
|
1a48d303f7 | ||
|
|
5bb9ca970c | ||
|
|
2947b558a2 | ||
|
|
98a5fa9941 | ||
|
|
712350d669 | ||
|
|
1a287be323 | ||
|
|
014750f91e | ||
|
|
574078bdc8 | ||
|
|
7ab80ac3f4 | ||
|
|
a258910ccf | ||
|
|
e15a85bd61 | ||
|
|
ced90211a0 | ||
|
|
4ae8426766 | ||
|
|
32a5ca8799 | ||
|
|
5d77b8804f | ||
|
|
544502c6e5 | ||
|
|
1ef540679b | ||
|
|
18f38818e2 | ||
|
|
02e6333c3c | ||
|
|
cb47439e9f | ||
|
|
4bbfe2e889 | ||
|
|
187a0fc3d4 | ||
|
|
4e4d3c3971 | ||
|
|
6267e08a51 | ||
|
|
f2c23636fe | ||
|
|
11c89d150a | ||
|
|
6b75f9bf7c | ||
|
|
91de9e7fb7 | ||
|
|
21fb320f3a | ||
|
|
3586ac9437 | ||
|
|
b0ed75382e | ||
|
|
b1646cd83b | ||
|
|
7abc74ac98 | ||
|
|
a1b10c7469 | ||
|
|
0d75629dcb | ||
|
|
849ea26cf9 | ||
|
|
4a66cf6e11 | ||
|
|
41c7cfbdaa | ||
|
|
7fb57d8090 | ||
|
|
618258d282 | ||
|
|
e50568dba3 | ||
|
|
5fa894cb79 | ||
|
|
6d4e944a58 | ||
|
|
cfe7d2a359 | ||
|
|
5fb26367ed | ||
|
|
cd65488cbd | ||
|
|
4ea6539a7d | ||
|
|
a75bb5fae6 | ||
|
|
380968472f | ||
|
|
0cb48596a0 | ||
|
|
22d0123966 | ||
|
|
2a45b4d46f | ||
|
|
bdd4634a3b | ||
|
|
a5e66318b5 | ||
|
|
7ebbb05482 | ||
|
|
99c62961cf | ||
|
|
40a9f3f1e1 | ||
|
|
b37e4d9083 | ||
|
|
37dd9fad26 | ||
|
|
0f51d68ff0 | ||
|
|
dfc048522d | ||
|
|
1f9072a905 | ||
|
|
780ed8147b | ||
|
|
b2cfc60387 | ||
|
|
c8f84e9e20 | ||
|
|
9434cd8929 | ||
|
|
a277a89bef | ||
|
|
55184724d0 | ||
|
|
de4c528d55 | ||
|
|
d5e887903e | ||
|
|
6196b81fe4 | ||
|
|
c3598a67fa | ||
|
|
2c896a5506 | ||
|
|
97e23e4b3a | ||
|
|
be39ad0bfe | ||
|
|
7f0e577dd2 | ||
|
|
f308325bd6 | ||
|
|
c5303add56 | ||
|
|
fe6f755451 | ||
|
|
4a2429d316 | ||
|
|
d7f8bcc9e1 | ||
|
|
d51fdbd772 | ||
|
|
199d68e8a2 | ||
|
|
461a5e35dd | ||
|
|
8dd57d45e2 | ||
|
|
01e9655f42 | ||
|
|
d4d2735d23 | ||
|
|
253642dde9 | ||
|
|
9d0dc3f330 | ||
|
|
2f3de9797d | ||
|
|
1b3b8e5312 | ||
|
|
b55560e2f1 | ||
|
|
57ddbc474f | ||
|
|
862ff15eef | ||
|
|
da8b230cec | ||
|
|
13799af721 | ||
|
|
4fcb13694b | ||
|
|
3dc529733a | ||
|
|
d865103fb5 | ||
|
|
4335e6b7b6 | ||
|
|
4f5dba560d | ||
|
|
f52acb3f68 | ||
|
|
f4864b5455 | ||
|
|
f299098548 | ||
|
|
87eab6f996 | ||
|
|
31400a2502 | ||
|
|
fa0c0abcac | ||
|
|
9102335d3e | ||
|
|
011b93ecc1 | ||
|
|
20ce11497e | ||
|
|
50d0721822 | ||
|
|
c66872c7f4 | ||
|
|
b1be38368c | ||
|
|
278f652ea9 | ||
|
|
d3e12722b7 | ||
|
|
8c9ff41074 | ||
|
|
71a1194373 | ||
|
|
580a025353 | ||
|
|
33994cb3df | ||
|
|
44edf320a0 | ||
|
|
fea2ef4dbe | ||
|
|
360b4b8035 | ||
|
|
3857a71e62 | ||
|
|
b68367b04f | ||
|
|
08f104e283 | ||
|
|
46eb0b4063 | ||
|
|
2632f98ad0 | ||
|
|
84539f76cf | ||
|
|
308672d558 | ||
|
|
0f474d82d6 | ||
|
|
650cfe00ce | ||
|
|
69eb9957c6 | ||
|
|
ec01a5dfe4 | ||
|
|
b2ace1bec5 | ||
|
|
5da472da14 | ||
|
|
4bb366f5ab | ||
|
|
cc4b8b38f5 | ||
|
|
f41e0f0619 | ||
|
|
3f10b6e567 | ||
|
|
9ad20daa51 | ||
|
|
86b59da909 | ||
|
|
6a9068a151 | ||
|
|
b31ffa5318 | ||
|
|
71dd998390 | ||
|
|
102a9f1484 | ||
|
|
581ec9382b | ||
|
|
cebe9350f7 | ||
|
|
406518e0a5 | ||
|
|
c458d5fb65 | ||
|
|
ba0d685c6d | ||
|
|
912f1f960d | ||
|
|
1de0edf7e3 | ||
|
|
8c9cb4be77 | ||
|
|
727b904ca9 | ||
|
|
5c0279a6a7 | ||
|
|
9d72d20dd2 | ||
|
|
62d0630702 | ||
|
|
2260b4a4d2 | ||
|
|
cfb50afe42 | ||
|
|
b14b2142e3 | ||
|
|
896d73f12e | ||
|
|
c64c4cc5ff | ||
|
|
7071de294f | ||
|
|
5eb16fdbdb | ||
|
|
131fd1a6b0 | ||
|
|
f9bc02ed1c | ||
|
|
1035e397e0 | ||
|
|
9c788561a7 | ||
|
|
449e96d842 | ||
|
|
c7626c669a | ||
|
|
ede5bc4ecd | ||
|
|
8bb60bfd03 | ||
|
|
f009aa17d9 | ||
|
|
ef7ac8bef8 | ||
|
|
b75f895a1f | ||
|
|
53f5b61e8e | ||
|
|
64a2c69540 | ||
|
|
c4ec14db5d | ||
|
|
4560820649 | ||
|
|
3051bc7edc | ||
|
|
22232489a8 | ||
|
|
04277a6eb4 | ||
|
|
a8dc867e78 | ||
|
|
533783ba3e | ||
|
|
fd7b3e5f20 | ||
|
|
ae4b851620 | ||
|
|
e83f97a05a | ||
|
|
070682af74 | ||
|
|
8609e4bca0 | ||
|
|
2661d7a384 | ||
|
|
5b9d554f46 | ||
|
|
7fb8bab4be | ||
|
|
75d29124b6 | ||
|
|
1f0ea6cedc | ||
|
|
07a38b9c2c | ||
|
|
67806c94b1 | ||
|
|
2eeb088b61 | ||
|
|
9c796edbde | ||
|
|
84a1b994a1 | ||
|
|
db4689207a | ||
|
|
af465766c8 | ||
|
|
c9b99eabb4 | ||
|
|
c13f26a4c3 |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
|
|||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||||
|
|
||||||
### Backend Layers
|
### Backend Layers
|
||||||
|
|||||||
@@ -92,7 +92,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).
|
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -104,7 +104,15 @@ 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:
|
||||||
|
|
||||||
@@ -118,12 +126,122 @@ 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), [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) (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.
|
||||||
|
|
||||||
### 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 reports on every container running on a host. It can connect to the Docker daemon directly or read container metrics from a Capture agent. Direct monitors use the daemon's ping response for availability and record container state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every direct check; logs are kept for 7 days.
|
||||||
|
|
||||||
|
The **Docker host** field accepts three forms:
|
||||||
|
|
||||||
|
| Host | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
|
||||||
|
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
|
||||||
|
| Capture agent | `https://capture.example.com/api/v1/metrics/docker` | Enter the full endpoint ending in `/metrics/docker` and its authorization secret. Checkmate requests `all=true` so stopped containers are included. |
|
||||||
|
|
||||||
|
Capture-backed monitors normalize Capture's container state, health, CPU, memory, start time and exposed ports into the same Docker pages. Capture does not currently provide restart counts, mounts, published host-port bindings or container logs, so those values are shown as unavailable. Capture also reports a container whose health check is still starting as healthy, so the `starting` health state only appears on direct monitors, and stopped containers carry no CPU or memory metrics.
|
||||||
|
|
||||||
|
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
checkmate:
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
group_add:
|
||||||
|
- "989" # the gid printed by stat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
|
||||||
|
|
||||||
|
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
|
||||||
|
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
|
||||||
|
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
|
||||||
|
|
||||||
|
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
|
||||||
|
|
||||||
|
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
|
||||||
|
|
||||||
|
### Securing the Docker daemon with TLS
|
||||||
|
|
||||||
|
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
|
||||||
|
|
||||||
|
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -aes256 -out ca-key.pem 4096
|
||||||
|
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -out server-key.pem 4096
|
||||||
|
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
|
||||||
|
cat > server-ext.cnf <<EOF
|
||||||
|
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
EOF
|
||||||
|
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out server-cert.pem -extfile server-ext.cnf
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -out key.pem 4096
|
||||||
|
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
|
||||||
|
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
|
||||||
|
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out cert.pem -extfile client-ext.cnf
|
||||||
|
rm client.csr server.csr server-ext.cnf client-ext.cnf
|
||||||
|
chmod 0400 ca-key.pem key.pem server-key.pem
|
||||||
|
chmod 0444 ca.pem server-cert.pem cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
|
||||||
|
"tls": true,
|
||||||
|
"tlsverify": true,
|
||||||
|
"tlscacert": "/etc/docker/certs/ca.pem",
|
||||||
|
"tlscert": "/etc/docker/certs/server-cert.pem",
|
||||||
|
"tlskey": "/etc/docker/certs/server-key.pem"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl edit docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/dockerd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Open port `2376` on the host firewall only to the machine running Checkmate.
|
||||||
|
|
||||||
|
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
|
||||||
|
-H=docker.example.com:2376 version
|
||||||
|
```
|
||||||
|
|
||||||
For more documentation, see the [docs directory](./docs/).
|
For more documentation, see the [docs directory](./docs/).
|
||||||
|
|
||||||
<a id="performance"></a>
|
<a id="performance"></a>
|
||||||
@@ -133,7 +251,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
|
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -151,16 +269,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: Uptime, Docker, Ping, SSL, Port, Game server
|
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, 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 4 beautiful themes
|
- Status pages with 5 beautiful themes
|
||||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
|
|
||||||
|
|
||||||
## Monitor Lifecycle
|
## Monitor Lifecycle
|
||||||
@@ -213,7 +331,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
|
|
||||||
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), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||||
|
|
||||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 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!
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-24
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "3.11.0",
|
"version": "3.12.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",
|
||||||
@@ -93,7 +93,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 +408,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 +445,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",
|
||||||
@@ -1236,7 +1233,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 +1337,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 +2381,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 +2396,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 +2486,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 +2766,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 +3122,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 +3565,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 +3964,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 +4776,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -5600,7 +5587,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 +6368,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 +6379,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 +6582,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 +6732,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 +7573,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 +7710,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",
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.11.0",
|
"version": "3.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
if (!monitor) {
|
if (!monitor) {
|
||||||
@@ -52,7 +55,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
<Typography>
|
<Typography>
|
||||||
Checking every {formatDuration(monitor?.interval, true)}
|
{t("pages.common.monitors.statBoxes.checkingEvery", {
|
||||||
|
interval: formatDuration(monitor.interval, {
|
||||||
|
long: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ export function DataTable<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const hasTextSelectionWithin = (element: Element) => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
selection.anchorNode !== null &&
|
||||||
|
element.contains(
|
||||||
|
selection.anchorNode.nodeType === Node.TEXT_NODE
|
||||||
|
? selection.anchorNode.parentNode
|
||||||
|
: selection.anchorNode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
const isInteractive = Boolean(onRowClick) || expandableRows;
|
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||||
|
|
||||||
if (data.length === 0 || headers.length === 0) {
|
if (data.length === 0 || headers.length === 0) {
|
||||||
@@ -102,7 +117,10 @@ export function DataTable<
|
|||||||
keys.push(key);
|
keys.push(key);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
onClick={(e) => {
|
||||||
|
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",
|
||||||
@@ -256,7 +274,8 @@ export function DataTable<
|
|||||||
}),
|
}),
|
||||||
...(getRowSx?.(row) as object),
|
...(getRowSx?.(row) as object),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||||
if (expandableRows) handleExpand(row);
|
if (expandableRows) handleExpand(row);
|
||||||
else if (onRowClick) onRowClick(row);
|
else if (onRowClick) onRowClick(row);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Button } from "@/Components/inputs";
|
|||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SxProps } from "@mui/material/styles";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export const DialogInput = ({
|
export const DialogInput = ({
|
||||||
@@ -26,8 +25,6 @@ export const DialogInput = ({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
additionalButtons,
|
additionalButtons,
|
||||||
cancelSx,
|
|
||||||
onEscape,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -42,14 +39,6 @@ export const DialogInput = ({
|
|||||||
maxWidth?: DialogProps["maxWidth"];
|
maxWidth?: DialogProps["maxWidth"];
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
additionalButtons?: ReactNode;
|
additionalButtons?: ReactNode;
|
||||||
/** Styles the cancel button — e.g. an auto margin to push it left. */
|
|
||||||
cancelSx?: SxProps;
|
|
||||||
/**
|
|
||||||
* Called when Escape is pressed. Defaults to `onCancel`, which is right when
|
|
||||||
* cancel simply dismisses — but not when it does something else, so a dialog
|
|
||||||
* whose cancel slot carries another action passes its own dismiss here.
|
|
||||||
*/
|
|
||||||
onEscape?(): void;
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -59,12 +48,7 @@ export const DialogInput = ({
|
|||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
onClose={(_event, reason) => {
|
onClose={(_event, reason) => {
|
||||||
if (reason === "backdropClick") return;
|
if (reason !== "backdropClick" && onCancel) onCancel(undefined);
|
||||||
if (onEscape) {
|
|
||||||
onEscape();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (onCancel) onCancel(undefined);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
@@ -110,7 +94,6 @@ export const DialogInput = ({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
sx={cancelSx}
|
|
||||||
>
|
>
|
||||||
{cancelText ?? t("common.buttons.cancel")}
|
{cancelText ?? t("common.buttons.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
type FieldValues,
|
type FieldValues,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
import { Autocomplete } from "@/Components/inputs";
|
||||||
|
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
||||||
|
|
||||||
export interface AutocompleteOption {
|
export interface AutocompleteOption {
|
||||||
id: string;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
|
|||||||
options: O[];
|
options: O[];
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
|
||||||
|
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
|
||||||
|
disableClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormAutocompleteField = <
|
export const FormAutocompleteField = <
|
||||||
@@ -26,6 +30,9 @@ export const FormAutocompleteField = <
|
|||||||
options,
|
options,
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
filterOptions,
|
||||||
|
getOptionDisabled,
|
||||||
|
disableClearable,
|
||||||
}: FormAutocompleteProps<T, O>) => {
|
}: FormAutocompleteProps<T, O>) => {
|
||||||
const { control } = useFormContext<T>();
|
const { control } = useFormContext<T>();
|
||||||
return (
|
return (
|
||||||
@@ -39,10 +46,14 @@ export const FormAutocompleteField = <
|
|||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
value={options.find((o) => o.id === field.value) ?? null}
|
value={options.find((o) => o.id === field.value) ?? null}
|
||||||
onChange={(_: unknown, newValue: O | null) => {
|
onChange={(_: unknown, newValue: O | null) => {
|
||||||
|
if (newValue === null && disableClearable) return;
|
||||||
field.onChange(newValue?.id ?? "");
|
field.onChange(newValue?.id ?? "");
|
||||||
}}
|
}}
|
||||||
fieldLabel={fieldLabel}
|
fieldLabel={fieldLabel}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
getOptionDisabled={getOptionDisabled}
|
||||||
|
disableClearable={disableClearable}
|
||||||
error={!!fieldState.error}
|
error={!!fieldState.error}
|
||||||
helperText={fieldState.error?.message ?? ""}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
|
|||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
gap={theme.spacing(2)}
|
gap={theme.spacing(2)}
|
||||||
>
|
>
|
||||||
<Button
|
{isAdmin && (
|
||||||
variant="contained"
|
<Button
|
||||||
color="secondary"
|
variant="contained"
|
||||||
loading={isPosting}
|
color="secondary"
|
||||||
startIcon={<Icon icon={Mail} />}
|
loading={isPosting}
|
||||||
onClick={async () => {
|
startIcon={<Icon icon={Mail} />}
|
||||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
onClick={async () => {
|
||||||
}}
|
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||||
sx={{
|
}}
|
||||||
whiteSpace: "nowrap",
|
sx={{
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
>
|
}}
|
||||||
{t("common.buttons.testNotifications")}
|
>
|
||||||
</Button>
|
{t("common.buttons.testNotifications")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
|
|||||||
domainExpiry,
|
domainExpiry,
|
||||||
}: MonitorStatBoxesProps) => {
|
}: MonitorStatBoxesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
if (!monitorStats || !monitor) {
|
if (!monitorStats || !monitor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -29,13 +29,16 @@ export const MonitorStatBoxes = ({
|
|||||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||||
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
||||||
|
|
||||||
// Determine time since last check
|
|
||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
|
||||||
|
|
||||||
const streakTime = formatDuration(timeSinceLastFailure);
|
const streakTime = formatDuration(timeSinceLastFailure);
|
||||||
|
const lastCheckTime = timeOfLastCheck
|
||||||
const lastCheckTime = formatDuration(timeSinceLastCheck);
|
? formatDuration(Date.now() - timeOfLastCheck, {
|
||||||
|
long: true,
|
||||||
|
relative: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
})
|
||||||
|
: t("common.labels.na");
|
||||||
const isActive =
|
const isActive =
|
||||||
monitor?.status === "up" ||
|
monitor?.status === "up" ||
|
||||||
monitor?.status === "paused" ||
|
monitor?.status === "paused" ||
|
||||||
|
|||||||
@@ -1,15 +1,58 @@
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||||
|
import type { TooltipProps } from "recharts";
|
||||||
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import type { RootState } from "@/Types/state";
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { Fragment, useId } from "react";
|
import { Fragment, useId } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
|
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
|
dataKey: string;
|
||||||
|
range: string;
|
||||||
|
theme: Theme;
|
||||||
|
uiTimezone: string;
|
||||||
|
valueFormatter: (value: number) => string;
|
||||||
|
labelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DockerHistogramToolTip = ({
|
||||||
|
dataKey,
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
range,
|
||||||
|
theme,
|
||||||
|
uiTimezone,
|
||||||
|
valueFormatter,
|
||||||
|
labelKey,
|
||||||
|
}: DockerHistogramToolTipProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!payload) return null;
|
||||||
|
if (!active) return null;
|
||||||
|
const format = tooltipDateFormatLookup(range);
|
||||||
|
const ts = payload[0].payload?._id ?? null;
|
||||||
|
const dataPoint = payload[0].payload?.[dataKey] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
|
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
|
||||||
|
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
|
||||||
|
</BaseBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
interface HistogramDockerContainerProps {
|
interface HistogramDockerContainerProps {
|
||||||
title: string;
|
title: string;
|
||||||
rightTitle: string;
|
rightTitle: string;
|
||||||
@@ -17,6 +60,9 @@ interface HistogramDockerContainerProps {
|
|||||||
dataKey: string;
|
dataKey: string;
|
||||||
strokeColor: string;
|
strokeColor: string;
|
||||||
gradientStartColor: string;
|
gradientStartColor: string;
|
||||||
|
range: DateRange;
|
||||||
|
formatter: (value: number) => string;
|
||||||
|
labelKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistogramDockerContainer = ({
|
export const HistogramDockerContainer = ({
|
||||||
@@ -26,11 +72,14 @@ export const HistogramDockerContainer = ({
|
|||||||
dataKey,
|
dataKey,
|
||||||
strokeColor,
|
strokeColor,
|
||||||
gradientStartColor,
|
gradientStartColor,
|
||||||
|
range,
|
||||||
|
formatter,
|
||||||
|
labelKey,
|
||||||
}: HistogramDockerContainerProps) => {
|
}: HistogramDockerContainerProps) => {
|
||||||
const uniqueId = useId();
|
const uniqueId = useId();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const gradientId = `gradient-${uniqueId}`;
|
const gradientId = `gradient-${uniqueId}`;
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
return (
|
return (
|
||||||
<BaseChart
|
<BaseChart
|
||||||
icon="null"
|
icon="null"
|
||||||
@@ -56,6 +105,19 @@ export const HistogramDockerContainer = ({
|
|||||||
endColor: "transparent",
|
endColor: "transparent",
|
||||||
direction: "vertical",
|
direction: "vertical",
|
||||||
})}
|
})}
|
||||||
|
<Tooltip
|
||||||
|
content={(props) => (
|
||||||
|
<DockerHistogramToolTip
|
||||||
|
{...props}
|
||||||
|
range={range}
|
||||||
|
theme={theme}
|
||||||
|
uiTimezone={uiTimezone}
|
||||||
|
dataKey={dataKey}
|
||||||
|
valueFormatter={formatter}
|
||||||
|
labelKey={labelKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Area
|
<Area
|
||||||
dataKey={dataKey}
|
dataKey={dataKey}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Icon } from "@/Components/design-elements";
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
|
||||||
Globe,
|
Globe,
|
||||||
Gauge,
|
Gauge,
|
||||||
Link,
|
Link,
|
||||||
@@ -21,17 +20,18 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Container,
|
Container,
|
||||||
|
// LayoutDashboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
export const getMenu = (t: Function) => {
|
export const getMenu = (t: Function) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.monitoring"),
|
group: t("components.sidebar.menu.groups.monitoring"),
|
||||||
items: [
|
items: [
|
||||||
{
|
// {
|
||||||
name: t("components.sidebar.menu.dashboard"),
|
// name: t("components.sidebar.menu.dashboard"),
|
||||||
path: "dashboard",
|
// path: "dashboard",
|
||||||
icon: <Icon icon={LayoutDashboard} />,
|
// icon: <Icon icon={LayoutDashboard} />,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.uptime"),
|
name: t("components.sidebar.menu.uptime"),
|
||||||
path: "uptime",
|
path: "uptime",
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import {
|
||||||
|
dashboardCardKeys,
|
||||||
|
type DashboardCardKey,
|
||||||
|
type DashboardSortOrder,
|
||||||
|
} from "@/Types/Dashboard";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
type ThemeMode = "light" | "dark";
|
type ThemeMode = "light" | "dark";
|
||||||
type ChartType = "histogram" | "line";
|
type ChartType = "histogram" | "line";
|
||||||
|
|
||||||
type TableName =
|
type TableName =
|
||||||
|
| "dashboard"
|
||||||
| "monitors"
|
| "monitors"
|
||||||
| "team"
|
| "team"
|
||||||
| "maintenance"
|
| "maintenance"
|
||||||
@@ -13,6 +21,8 @@ type TableName =
|
|||||||
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
rowsPerPage: number;
|
rowsPerPage: number;
|
||||||
|
sortField?: string;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarState {
|
interface SidebarState {
|
||||||
@@ -20,6 +30,7 @@ interface SidebarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
|
dashboard: TableState;
|
||||||
monitors: TableState;
|
monitors: TableState;
|
||||||
pagespeed: TableState;
|
pagespeed: TableState;
|
||||||
team: TableState;
|
team: TableState;
|
||||||
@@ -35,6 +46,8 @@ interface UIState {
|
|||||||
language: string;
|
language: string;
|
||||||
starPromptOpen: boolean;
|
starPromptOpen: boolean;
|
||||||
chartType: ChartType;
|
chartType: ChartType;
|
||||||
|
dashboardVisibleCards: DashboardCardKey[];
|
||||||
|
dashboardUptimeSortOrder: DashboardSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||||
@@ -43,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
|
|||||||
: "light";
|
: "light";
|
||||||
|
|
||||||
const initialState: UIState = {
|
const initialState: UIState = {
|
||||||
|
dashboard: {
|
||||||
|
rowsPerPage: 100,
|
||||||
|
},
|
||||||
monitors: {
|
monitors: {
|
||||||
rowsPerPage: 10,
|
rowsPerPage: 10,
|
||||||
},
|
},
|
||||||
@@ -74,6 +90,8 @@ const initialState: UIState = {
|
|||||||
language: "en",
|
language: "en",
|
||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
chartType: "histogram",
|
||||||
|
dashboardVisibleCards: [...dashboardCardKeys],
|
||||||
|
dashboardUptimeSortOrder: "ascending",
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -90,6 +108,14 @@ const uiSlice = createSlice({
|
|||||||
const { table, value } = action.payload;
|
const { table, value } = action.payload;
|
||||||
state[table].rowsPerPage = value;
|
state[table].rowsPerPage = value;
|
||||||
},
|
},
|
||||||
|
setTableSort: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
|
||||||
|
) => {
|
||||||
|
const { table, field, order } = action.payload;
|
||||||
|
state[table].sortField = field;
|
||||||
|
state[table].sortOrder = order;
|
||||||
|
},
|
||||||
toggleSidebar: (state) => {
|
toggleSidebar: (state) => {
|
||||||
state.sidebar.collapsed = !state.sidebar.collapsed;
|
state.sidebar.collapsed = !state.sidebar.collapsed;
|
||||||
},
|
},
|
||||||
@@ -117,6 +143,12 @@ const uiSlice = createSlice({
|
|||||||
setChartType: (state, action: PayloadAction<ChartType>) => {
|
setChartType: (state, action: PayloadAction<ChartType>) => {
|
||||||
state.chartType = action.payload;
|
state.chartType = action.payload;
|
||||||
},
|
},
|
||||||
|
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
|
||||||
|
state.dashboardVisibleCards = action.payload;
|
||||||
|
},
|
||||||
|
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
|
||||||
|
state.dashboardUptimeSortOrder = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
|
|||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
export const {
|
export const {
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
|
setTableSort,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
setMode,
|
setMode,
|
||||||
@@ -133,4 +166,6 @@ export const {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
setChartType,
|
||||||
|
setDashboardVisibleCards,
|
||||||
|
setDashboardUptimeSortOrder,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
@@ -77,7 +77,16 @@ export const getMonitorDefaults = (
|
|||||||
...base,
|
...base,
|
||||||
type: "docker",
|
type: "docker",
|
||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
|
secret: data?.secret || "",
|
||||||
|
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||||
|
dockerTlsCa: data?.dockerTlsCa || "",
|
||||||
|
dockerTlsCert: data?.dockerTlsCert || "",
|
||||||
|
// The key is write-only; the API never returns it.
|
||||||
|
dockerTlsKey: "",
|
||||||
|
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
||||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||||
|
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
|
||||||
|
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "game":
|
case "game":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
|
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
|
||||||
import { useDelete, useGet } from "@/Hooks/UseApi";
|
import { useDelete, useGet } from "@/Hooks/UseApi";
|
||||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MonitorType,
|
type MonitorType,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -24,10 +25,12 @@ interface MonitorListConfig {
|
|||||||
|
|
||||||
export const useMonitorListController = (config: MonitorListConfig) => {
|
export const useMonitorListController = (config: MonitorListConfig) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const rowsPerPage = useSelector(
|
const tableState = useSelector(
|
||||||
(state: RootState) =>
|
(state: RootState) => state.ui?.[config.rowsPerPageTable]
|
||||||
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
|
|
||||||
);
|
);
|
||||||
|
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
|
||||||
|
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
|
||||||
|
const sortOrder = tableState?.sortOrder ?? "asc";
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
||||||
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [page, setPage] = useState<number>(0);
|
const [page, setPage] = useState<number>(0);
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
|
|
||||||
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSort = (field: string, order: SortOrder) => {
|
||||||
|
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
// Check for active filters
|
// Check for active filters
|
||||||
const hasActiveFilters = Boolean(
|
const hasActiveFilters = Boolean(
|
||||||
selectedTypes.length > 0 ||
|
selectedTypes.length > 0 ||
|
||||||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
handleSetRowsPerPage,
|
handleSetRowsPerPage,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
selectedMonitor,
|
selectedMonitor,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
PageSpeedStrategies,
|
PageSpeedStrategies,
|
||||||
DnsRecordTypes,
|
DnsRecordTypes,
|
||||||
MonitorIntervalOptions,
|
MonitorIntervalOptions,
|
||||||
|
MIN_MONITOR_INTERVAL_MS,
|
||||||
DefaultMonitorMatchMethod,
|
DefaultMonitorMatchMethod,
|
||||||
MonitorMatchMethods,
|
MonitorMatchMethods,
|
||||||
GeoCheckIntervalOptions,
|
GeoCheckIntervalOptions,
|
||||||
@@ -50,6 +51,8 @@ import type { AppSettingsResponse } from "@/Types/Settings";
|
|||||||
import {
|
import {
|
||||||
stepFieldsFor,
|
stepFieldsFor,
|
||||||
monitorStepCount,
|
monitorStepCount,
|
||||||
|
isCaptureDockerUrl,
|
||||||
|
isDockerTlsUrl,
|
||||||
type MonitorFormData,
|
type MonitorFormData,
|
||||||
} from "@/Validation/monitor";
|
} from "@/Validation/monitor";
|
||||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||||
@@ -57,15 +60,62 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|||||||
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
|
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
||||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||||
|
import {
|
||||||
|
FormAutocompleteField,
|
||||||
|
type AutocompleteOption,
|
||||||
|
} from "@/Components/inputs/forms/FormAutoCompleteField";
|
||||||
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
||||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
import type { ProxyResponse } from "@/Types/Proxy";
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
const httpMethodOptions = HttpMethods.map((method) => ({
|
const httpMethodOptions = HttpMethods.map((method) => ({
|
||||||
value: method,
|
value: method,
|
||||||
label: method,
|
label: method,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const PEM_FIELD_ROWS = 6;
|
||||||
|
|
||||||
|
// Hides the pasted key while the field is not focused. Ignored by browsers
|
||||||
|
// without -webkit-text-security, which then show the key as plain text.
|
||||||
|
const maskedInputSx = {
|
||||||
|
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IntervalOption extends AutocompleteOption {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
|
||||||
|
|
||||||
|
const toIntervalOption = (
|
||||||
|
intervalMs: number,
|
||||||
|
locale: string | undefined
|
||||||
|
): IntervalOption => ({
|
||||||
|
id: intervalMs,
|
||||||
|
name: formatDuration(intervalMs, { long: true, locale }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterIntervalOptions = (
|
||||||
|
options: IntervalOption[],
|
||||||
|
inputValue: string,
|
||||||
|
locale: string | undefined
|
||||||
|
): IntervalOption[] => {
|
||||||
|
const input = inputValue.trim();
|
||||||
|
if (input === "" || options.some((option) => option.name === input)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(input);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
|
||||||
|
.filter(Number.isSafeInteger)
|
||||||
|
.map((ms) => toIntervalOption(ms, locale));
|
||||||
|
};
|
||||||
|
|
||||||
interface GeneralSettingsConfig {
|
interface GeneralSettingsConfig {
|
||||||
urlLabel: string;
|
urlLabel: string;
|
||||||
urlPlaceholder: string;
|
urlPlaceholder: string;
|
||||||
@@ -243,7 +293,7 @@ const getGeneralSettingsConfig = (
|
|||||||
|
|
||||||
const CreateMonitorPage = () => {
|
const CreateMonitorPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { monitorId } = useParams();
|
const { monitorId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -300,10 +350,13 @@ const CreateMonitorPage = () => {
|
|||||||
const showStep = (step: number) => isEditMode || currentStep === step;
|
const showStep = (step: number) => isEditMode || currentStep === step;
|
||||||
|
|
||||||
const watchedType = watch("type") as MonitorType;
|
const watchedType = watch("type") as MonitorType;
|
||||||
|
const watchedUrl = watch("url") as string;
|
||||||
|
const isCaptureDocker = watchedType === "docker" && isCaptureDockerUrl(watchedUrl);
|
||||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||||
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
||||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||||
|
const watchedInterval = watch("interval");
|
||||||
|
|
||||||
// Steps without an advanced section drop it, so the last step is the form's.
|
// Steps without an advanced section drop it, so the last step is the form's.
|
||||||
const totalSteps = monitorStepCount(watchedType);
|
const totalSteps = monitorStepCount(watchedType);
|
||||||
@@ -329,11 +382,20 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
}, [watchedType, showTypeSelector, reset, clearErrors]);
|
}, [watchedType, showTypeSelector, reset, clearErrors]);
|
||||||
|
|
||||||
|
// The logs switch is hidden for Capture hosts, so a value left over from a socket host must not be submitted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCaptureDocker) {
|
||||||
|
setValue("dockerLogsEnabled", false);
|
||||||
|
}
|
||||||
|
}, [isCaptureDocker, setValue]);
|
||||||
|
|
||||||
const generalSettingsConfig = useMemo(
|
const generalSettingsConfig = useMemo(
|
||||||
() => getGeneralSettingsConfig(watchedType, t),
|
() => getGeneralSettingsConfig(watchedType, t),
|
||||||
[watchedType, t]
|
[watchedType, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
|
||||||
|
|
||||||
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
|
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
|
||||||
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
|
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
|
||||||
const isSubmitting = isCreating || isUpdating;
|
const isSubmitting = isCreating || isUpdating;
|
||||||
@@ -459,16 +521,18 @@ const CreateMonitorPage = () => {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const intervalOptions = useMemo(
|
const intervalOptions = useMemo(() => {
|
||||||
() =>
|
const options = MonitorIntervalOptions.map(({ value }) =>
|
||||||
MonitorIntervalOptions.map((option) => ({
|
toIntervalOption(value, i18n.resolvedLanguage)
|
||||||
value: option.value,
|
);
|
||||||
label: t(
|
if (
|
||||||
`pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
|
typeof watchedInterval === "number" &&
|
||||||
),
|
!options.some((option) => option.id === watchedInterval)
|
||||||
})),
|
) {
|
||||||
[t]
|
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage));
|
||||||
);
|
}
|
||||||
|
return options;
|
||||||
|
}, [watchedInterval, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
const matchMethodOptions = useMemo(
|
const matchMethodOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -553,7 +617,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(0) && (
|
{showStep(0) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.general.title")}
|
title={t("pages.createMonitor.form.general.title")}
|
||||||
@@ -654,8 +717,8 @@ const CreateMonitorPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secret field - only for hardware type */}
|
{/* Capture API secret - used by hardware and Capture-backed Docker monitors */}
|
||||||
{generalSettingsConfig.showSecret && (
|
{(generalSettingsConfig.showSecret || isCaptureDocker) && (
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name="secret"
|
name="secret"
|
||||||
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
|
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
|
||||||
@@ -664,6 +727,12 @@ const CreateMonitorPage = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isCaptureDocker && (
|
||||||
|
<FormSwitchField
|
||||||
|
name="ignoreTlsErrors"
|
||||||
|
label={t("pages.createMonitor.form.ignoreTls.option.tls.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{generalSettingsConfig.showDnsServer && (
|
{generalSettingsConfig.showDnsServer && (
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name="dnsServer"
|
name="dnsServer"
|
||||||
@@ -688,23 +757,83 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerTls.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerTls.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<FormTextField
|
||||||
|
name="dockerTlsCa"
|
||||||
|
multiline
|
||||||
|
rows={PEM_FIELD_ROWS}
|
||||||
|
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
|
||||||
|
)}
|
||||||
|
slotProps={{ input: { sx: pemInputSx } }}
|
||||||
|
/>
|
||||||
|
<FormTextField
|
||||||
|
name="dockerTlsCert"
|
||||||
|
multiline
|
||||||
|
rows={PEM_FIELD_ROWS}
|
||||||
|
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
|
||||||
|
)}
|
||||||
|
slotProps={{ input: { sx: pemInputSx } }}
|
||||||
|
/>
|
||||||
|
<FormTextField
|
||||||
|
name="dockerTlsKey"
|
||||||
|
multiline
|
||||||
|
rows={PEM_FIELD_ROWS}
|
||||||
|
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.createMonitor.form.dockerTls.option.key.placeholder"
|
||||||
|
)}
|
||||||
|
helperText={
|
||||||
|
existingMonitor?.dockerTlsKeySet
|
||||||
|
? t("pages.createMonitor.form.dockerTls.option.key.stored")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name="ignoreTlsErrors"
|
||||||
|
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.frequency.title")}
|
title={t("pages.createMonitor.form.frequency.title")}
|
||||||
subtitle={t("pages.createMonitor.form.frequency.description")}
|
subtitle={t("pages.createMonitor.form.frequency.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<FormSelectField
|
<FormAutocompleteField
|
||||||
name="interval"
|
name="interval"
|
||||||
fieldLabel={t(
|
fieldLabel={t(
|
||||||
"pages.createMonitor.form.frequency.option.frequency.label"
|
"pages.createMonitor.form.frequency.option.frequency.label"
|
||||||
)}
|
)}
|
||||||
options={intervalOptions}
|
options={intervalOptions}
|
||||||
|
filterOptions={(options, { inputValue }) =>
|
||||||
|
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
|
||||||
|
}
|
||||||
|
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
|
||||||
|
disableClearable
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert Thresholds - only for hardware type */}
|
{/* Alert Thresholds - only for hardware type */}
|
||||||
{watchedType === "hardware" && showStep(1) && (
|
{watchedType === "hardware" && showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
@@ -760,7 +889,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.incidents.title")}
|
title={t("pages.createMonitor.form.incidents.title")}
|
||||||
@@ -787,7 +915,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.notifications.title")}
|
title={t("pages.createMonitor.form.notifications.title")}
|
||||||
@@ -800,7 +927,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.tags.title")}
|
title={t("pages.createMonitor.form.tags.title")}
|
||||||
@@ -827,8 +953,7 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
|
||||||
{showStep(1) && watchedType === "docker" && (
|
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.dockerLogs.title")}
|
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||||
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
||||||
@@ -840,7 +965,28 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerAlerts.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerAlerts.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnStopped"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onStopped.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnUnhealthy"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onUnhealthy.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showStep(2) &&
|
{showStep(2) &&
|
||||||
(watchedType === "http" ||
|
(watchedType === "http" ||
|
||||||
watchedType === "grpc" ||
|
watchedType === "grpc" ||
|
||||||
@@ -856,7 +1002,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && watchedType === "http" && (
|
{showStep(2) && watchedType === "http" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.advanced.title")}
|
title={t("pages.createMonitor.form.advanced.title")}
|
||||||
@@ -956,7 +1101,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && supportsGeoCheck(watchedType) && (
|
{showStep(2) && supportsGeoCheck(watchedType) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||||
@@ -989,7 +1133,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
|
|
||||||
import { layoutCards } from "../layout";
|
|
||||||
import { useCardTransitions } from "../useCardTransitions";
|
|
||||||
import { CardReorderControls } from "./CardReorderControls";
|
|
||||||
import { CardSlotContext } from "./CardSlotContext";
|
|
||||||
|
|
||||||
export const CardGrid = ({
|
|
||||||
cards,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
cards: CardDefinition[];
|
|
||||||
onMove: (id: CardId, delta: -1 | 1) => void;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const placed = useMemo(() => layoutCards(cards), [cards]);
|
|
||||||
const { registerCard, capturePositions } = useCardTransitions();
|
|
||||||
|
|
||||||
// Positions are measured before the state change so the cards can be
|
|
||||||
// animated from where they were to where they land.
|
|
||||||
const handleMove = (id: CardId, delta: -1 | 1) => {
|
|
||||||
capturePositions(id);
|
|
||||||
onMove(id, delta);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
display="grid"
|
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
|
||||||
sx={{
|
|
||||||
gridTemplateColumns: {
|
|
||||||
xs: "1fr",
|
|
||||||
md: `repeat(${GRID_COLUMNS}, 1fr)`,
|
|
||||||
},
|
|
||||||
alignItems: "stretch",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{placed.map(({ card, renderedWidth }, index) => {
|
|
||||||
const Component = card.component;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={card.id}
|
|
||||||
ref={(node: HTMLElement | null) => registerCard(card.id, node)}
|
|
||||||
minWidth={0}
|
|
||||||
// Positioned so the moving card can be lifted above its
|
|
||||||
// neighbours with a z-index while it travels.
|
|
||||||
position="relative"
|
|
||||||
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
|
|
||||||
// Hints the compositor before a transform is applied, so the
|
|
||||||
// first frame of a move does not stutter.
|
|
||||||
sx={{ willChange: "transform" }}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* The controls go through context rather than a prop so the 17
|
|
||||||
* card components stay unaware of their own position.
|
|
||||||
*/}
|
|
||||||
<CardSlotContext.Provider
|
|
||||||
value={
|
|
||||||
<CardReorderControls
|
|
||||||
canMoveUp={index > 0}
|
|
||||||
canMoveDown={index < placed.length - 1}
|
|
||||||
onMoveUp={() => handleMove(card.id, -1)}
|
|
||||||
onMoveDown={() => handleMove(card.id, 1)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Component />
|
|
||||||
</CardSlotContext.Provider>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { Dialog, Checkbox } from "@/Components/inputs";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { CardGroups, type CardDefinition, type CardGroup, type CardId } from "../cards";
|
|
||||||
|
|
||||||
interface CardPickerProps {
|
|
||||||
open: boolean;
|
|
||||||
available: CardDefinition[];
|
|
||||||
selected: CardId[];
|
|
||||||
onAdd: (id: CardId) => void;
|
|
||||||
onRemove: (id: CardId) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cards are grouped by sidebar section, not by load cost: you look for a card
|
|
||||||
* where you look for the feature it belongs to. Admin-only cards never reach
|
|
||||||
* `available` for a non-admin, so nobody is offered a card they cannot load.
|
|
||||||
*/
|
|
||||||
export const CardPicker = ({
|
|
||||||
open,
|
|
||||||
available,
|
|
||||||
selected,
|
|
||||||
onAdd,
|
|
||||||
onRemove,
|
|
||||||
onReset,
|
|
||||||
onClose,
|
|
||||||
}: CardPickerProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
|
||||||
return CardGroups.map((group: CardGroup) => ({
|
|
||||||
group,
|
|
||||||
cards: available.filter((card) => card.group === group),
|
|
||||||
})).filter((entry) => entry.cards.length > 0);
|
|
||||||
}, [available]);
|
|
||||||
|
|
||||||
const chosen = new Set(selected);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
title={t("pages.dashboard.picker.title")}
|
|
||||||
// The dialog lays its actions out as cancel → additional → confirm, all
|
|
||||||
// right-aligned. Reset takes the cancel slot with an auto right margin
|
|
||||||
// so it sits alone on the left, away from Done — a destructive action
|
|
||||||
// should not be adjacent to the one people reach for by reflex.
|
|
||||||
onCancel={onReset}
|
|
||||||
cancelText={t("pages.dashboard.picker.reset")}
|
|
||||||
cancelSx={{ marginRight: "auto" }}
|
|
||||||
// Escape must dismiss, not reset. The dialog sends Escape to onCancel by
|
|
||||||
// default, and this one's cancel slot resets the selection — so without
|
|
||||||
// this, Escape would wipe the user's cards instead of closing.
|
|
||||||
onEscape={onClose}
|
|
||||||
// Changes apply immediately, so Done only dismisses; it is the primary
|
|
||||||
// action here and carries the usual contained styling.
|
|
||||||
onConfirm={onClose}
|
|
||||||
confirmText={t("pages.dashboard.picker.done")}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* Two columns: seventeen cards in one list makes the dialog scroll,
|
|
||||||
* which hides most of the catalogue behind a scrollbar. Groups flow
|
|
||||||
* down the columns and are kept whole, so a group is never split
|
|
||||||
* across the gap.
|
|
||||||
*/}
|
|
||||||
<Box
|
|
||||||
pt={theme.spacing(LAYOUT.SM)}
|
|
||||||
sx={{
|
|
||||||
columnCount: { xs: 1, sm: 2 },
|
|
||||||
columnGap: theme.spacing(LAYOUT.XL),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{grouped.map(({ group, cards }) => (
|
|
||||||
<Stack
|
|
||||||
key={group}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
pb={theme.spacing(LAYOUT.MD)}
|
|
||||||
sx={{ breakInside: "avoid" }}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="eyebrow"
|
|
||||||
color={theme.palette.text.disabled}
|
|
||||||
>
|
|
||||||
{/*
|
|
||||||
* The picker has its own group labels rather than reusing the
|
|
||||||
* sidebar's: the Logs page hosts logs, queue and diagnostics
|
|
||||||
* tabs, so "Logs" would misdescribe the three cards under it.
|
|
||||||
*/}
|
|
||||||
{t(`pages.dashboard.picker.groups.${group}`, {
|
|
||||||
defaultValue: group,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
{cards.map((card) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={card.id}
|
|
||||||
label={
|
|
||||||
<Typography color={theme.palette.text.primary}>
|
|
||||||
{t(`pages.dashboard.cards.${card.key}.title`)}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={chosen.has(card.id)}
|
|
||||||
onChange={() =>
|
|
||||||
chosen.has(card.id) ? onRemove(card.id) : onAdd(card.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Tooltip } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One thickness for every bar on the dashboard. Plain and segmented bars sit
|
|
||||||
* side by side across cards, so differing weights read as an inconsistency
|
|
||||||
* rather than as emphasis.
|
|
||||||
*/
|
|
||||||
const BAR_HEIGHT = 8;
|
|
||||||
|
|
||||||
export interface BarSegment {
|
|
||||||
key: string;
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
/** Tooltip text, since the segments themselves carry no labels. */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A proportional bar. `value` and `max` share whatever unit the caller uses;
|
|
||||||
* percentages come in as 0-100, not as the API's 0-1 fractions.
|
|
||||||
*/
|
|
||||||
export const CardBar = ({
|
|
||||||
value,
|
|
||||||
max = 100,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
max?: number;
|
|
||||||
color: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const ratio = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
width="100%"
|
|
||||||
height={BAR_HEIGHT}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
bgcolor={theme.palette.action.hover}
|
|
||||||
sx={{ overflow: "hidden" }}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={`${ratio * 100}%`}
|
|
||||||
height="100%"
|
|
||||||
bgcolor={color}
|
|
||||||
sx={{ transition: "width 0.3s ease" }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One bar divided into proportional segments — the shape of a whole split into
|
|
||||||
* parts, where the relative sizes are the point rather than any single value.
|
|
||||||
*
|
|
||||||
* Zero-value segments are dropped so they cannot appear as hairlines, and each
|
|
||||||
* segment carries a tooltip because the bar itself has no room for labels.
|
|
||||||
*/
|
|
||||||
export const CardSegmentedBar = ({ segments }: { segments: BarSegment[] }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const visible = segments.filter((segment) => segment.value > 0);
|
|
||||||
const total = visible.reduce((sum, segment) => sum + segment.value, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
width="100%"
|
|
||||||
height={BAR_HEIGHT}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
bgcolor={theme.palette.action.hover}
|
|
||||||
gap="1px"
|
|
||||||
sx={{ overflow: "hidden" }}
|
|
||||||
>
|
|
||||||
{total > 0 &&
|
|
||||||
visible.map((segment) => (
|
|
||||||
<Tooltip
|
|
||||||
key={segment.key}
|
|
||||||
title={segment.label}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
width={`${(segment.value / total) * 100}%`}
|
|
||||||
height="100%"
|
|
||||||
bgcolor={segment.color}
|
|
||||||
sx={{ transition: "width 0.3s ease" }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A clickable list row. Rows link to the entity they describe. */
|
|
||||||
export const CardRow = ({ to, children }: { to?: string; children: ReactNode }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
{...(to ? { component: RouterLink, to } : {})}
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
px={theme.spacing(LAYOUT.XS)}
|
|
||||||
py={theme.spacing(LAYOUT.XS)}
|
|
||||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
color="inherit"
|
|
||||||
sx={{
|
|
||||||
textDecoration: "none",
|
|
||||||
...(to && {
|
|
||||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Name plus optional secondary line, truncating rather than wrapping. */
|
|
||||||
export const CardRowLabel = ({
|
|
||||||
primary,
|
|
||||||
secondary,
|
|
||||||
}: {
|
|
||||||
primary: string;
|
|
||||||
secondary?: string | null;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
minWidth={0}
|
|
||||||
flex={1}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{primary}
|
|
||||||
</Typography>
|
|
||||||
{secondary && (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{secondary}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A single large figure with a caption — the shape of the stat cards. */
|
|
||||||
export const CardFigure = ({
|
|
||||||
value,
|
|
||||||
caption,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
caption?: string;
|
|
||||||
color?: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
|
||||||
<Typography
|
|
||||||
fontSize={typographyLevels.xxl}
|
|
||||||
fontWeight={300}
|
|
||||||
color={color ?? theme.palette.text.primary}
|
|
||||||
lineHeight={1.1}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
{caption && <Typography color={theme.palette.text.secondary}>{caption}</Typography>}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A label with its value on the right — the shape almost every stat card
|
|
||||||
* repeats. `color` highlights the value alone, leaving the label quiet.
|
|
||||||
*/
|
|
||||||
export const CardStatLine = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
color?: string;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
|
||||||
<Typography
|
|
||||||
color={color ?? theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A CardStatLine with a proportional bar beneath it — the ranking and
|
|
||||||
* breakdown row. `max` defaults to a percentage scale; pass the largest value
|
|
||||||
* in the set to make bars relative to each other instead.
|
|
||||||
*
|
|
||||||
* `label` may be a node so callers can put a "N down" flag beside the name.
|
|
||||||
*/
|
|
||||||
export const CardMeterRow = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
max,
|
|
||||||
color,
|
|
||||||
trailing,
|
|
||||||
}: {
|
|
||||||
label: ReactNode;
|
|
||||||
value: number;
|
|
||||||
max?: number;
|
|
||||||
color: string;
|
|
||||||
trailing?: ReactNode;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
{typeof label === "string" ? (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
{trailing}
|
|
||||||
</Stack>
|
|
||||||
<CardBar
|
|
||||||
value={value}
|
|
||||||
max={max}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
import { Icon, Tooltip } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
|
|
||||||
// Snug around the 16px icon without inflating the card header.
|
|
||||||
const CONTROL_SIZE = 24;
|
|
||||||
const ICON_SIZE = 16;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move-up / move-down controls, revealed on hover over the card they belong to.
|
|
||||||
* Kept out of DashboardCard so the 17 card components stay unaware of their own
|
|
||||||
* position — the grid owns ordering.
|
|
||||||
*/
|
|
||||||
export const CardReorderControls = ({
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
canMoveUp,
|
|
||||||
canMoveDown,
|
|
||||||
}: {
|
|
||||||
onMoveUp: () => void;
|
|
||||||
onMoveDown: () => void;
|
|
||||||
canMoveUp: boolean;
|
|
||||||
canMoveDown: boolean;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const buttonSx = {
|
|
||||||
width: CONTROL_SIZE,
|
|
||||||
height: CONTROL_SIZE,
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
},
|
|
||||||
"&.Mui-disabled": { color: theme.palette.text.disabled },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
p={theme.spacing(LAYOUT.XXS)}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
bgcolor={theme.palette.background.paper}
|
|
||||||
border={`1px solid ${theme.palette.divider}`}
|
|
||||||
>
|
|
||||||
<Tooltip title={t("pages.dashboard.reorder.moveUp")}>
|
|
||||||
{/* span keeps the tooltip working while the button is disabled */}
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={!canMoveUp}
|
|
||||||
onClick={onMoveUp}
|
|
||||||
aria-label={t("pages.dashboard.reorder.moveUp")}
|
|
||||||
sx={buttonSx}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={ChevronUp}
|
|
||||||
size={ICON_SIZE}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t("pages.dashboard.reorder.moveDown")}>
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={!canMoveDown}
|
|
||||||
onClick={onMoveDown}
|
|
||||||
aria-label={t("pages.dashboard.reorder.moveDown")}
|
|
||||||
sx={buttonSx}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={ChevronDown}
|
|
||||||
size={ICON_SIZE}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export const CardSlotContext = createContext<ReactNode>(null);
|
|
||||||
|
|
||||||
export const useCardSlot = (): ReactNode => useContext(CardSlotContext);
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardFigure, CardRow, CardRowLabel, CardStatLine } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import { REFRESH_INTERVAL_MS, SLOW_REFRESH_INTERVAL_MS } from "../../cards";
|
|
||||||
|
|
||||||
import type { QueueMetrics } from "@/Types/Queue";
|
|
||||||
import type { Log } from "@/Types/Log";
|
|
||||||
import type { User } from "@/Types/User";
|
|
||||||
|
|
||||||
const MAX_ERROR_ROWS = 5;
|
|
||||||
|
|
||||||
/** Reads as a single reassurance line while nothing is failing. */
|
|
||||||
export const ChecksOnScheduleCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<QueueMetrics>(
|
|
||||||
"/queue/metrics",
|
|
||||||
{},
|
|
||||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthy = data?.failingJobs === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.checksOnSchedule.title")}
|
|
||||||
to="/logs"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{healthy ? (
|
|
||||||
<CardMessage
|
|
||||||
text={t("pages.dashboard.cards.checksOnSchedule.healthy", {
|
|
||||||
jobs: data.jobs,
|
|
||||||
workers: data.workers.length,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CardFigure
|
|
||||||
value={String(data.failingJobs)}
|
|
||||||
caption={t("pages.dashboard.cards.checksOnSchedule.failing")}
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
/>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{data.jobsWithFailures.slice(0, MAX_ERROR_ROWS).map((job) => (
|
|
||||||
<CardRow key={String(job.monitorId)}>
|
|
||||||
<CardRowLabel
|
|
||||||
primary={job.monitorUrl ?? String(job.monitorId)}
|
|
||||||
secondary={job.failReason}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{job.failCount}
|
|
||||||
</Typography>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checksOnSchedule.scheduled")}
|
|
||||||
value={String(data.jobs)}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checksOnSchedule.runningNow")}
|
|
||||||
value={String(data.activeJobs)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* /logs is an in-memory ring buffer capped at 1000 entries, wiped on restart,
|
|
||||||
* and it ignores page/rowsPerPage. It rotates fast, so this is deliberately
|
|
||||||
* worded "recent" rather than "all".
|
|
||||||
*/
|
|
||||||
export const RecentErrorsCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<Log[]>(
|
|
||||||
"/logs",
|
|
||||||
{},
|
|
||||||
{ refreshInterval: SLOW_REFRESH_INTERVAL_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { errors, warnings, newest } = useMemo(() => {
|
|
||||||
const relevant = (data ?? []).filter(
|
|
||||||
(entry) => entry.level === "error" || entry.level === "warn"
|
|
||||||
);
|
|
||||||
const sorted = [...relevant].sort(
|
|
||||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
errors: relevant.filter((entry) => entry.level === "error").length,
|
|
||||||
warnings: relevant.filter((entry) => entry.level === "warn").length,
|
|
||||||
newest: sorted.slice(0, MAX_ERROR_ROWS),
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.recentErrors.title")}
|
|
||||||
to="/logs"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
errors > 0 ? (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.recentErrors.count", { count: errors })}
|
|
||||||
</Typography>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{newest.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.recentErrors.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.recentErrors.warnings")}
|
|
||||||
value={String(warnings)}
|
|
||||||
/>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{newest.map((entry, index) => (
|
|
||||||
<CardRow key={`${entry.timestamp}-${index}`}>
|
|
||||||
<CardRowLabel
|
|
||||||
primary={entry.message}
|
|
||||||
secondary={[entry.service, entry.method].filter(Boolean).join(" · ")}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
color={
|
|
||||||
entry.level === "error"
|
|
||||||
? theme.palette.error.main
|
|
||||||
: theme.palette.warning.main
|
|
||||||
}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{formatTimestamp(entry.timestamp)}
|
|
||||||
</Typography>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Never empty — at minimum the current user exists. */
|
|
||||||
export const TeamCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<User[]>("/auth/users");
|
|
||||||
|
|
||||||
const { total, byRole, unverified } = useMemo(() => {
|
|
||||||
const users = data ?? [];
|
|
||||||
const roles = new Map<string, number>();
|
|
||||||
for (const user of users) {
|
|
||||||
for (const role of user.role ?? []) {
|
|
||||||
roles.set(role, (roles.get(role) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: users.length,
|
|
||||||
byRole: [...roles.entries()].sort((a, b) => b[1] - a[1]),
|
|
||||||
unverified: users.filter((user) => !user.isVerified).length,
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.team.title")}
|
|
||||||
to="/account/team"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardFigure
|
|
||||||
value={String(total)}
|
|
||||||
caption={t("pages.dashboard.cards.team.members", { count: total })}
|
|
||||||
/>
|
|
||||||
{byRole.map(([role, count]) => (
|
|
||||||
<CardStatLine
|
|
||||||
key={role}
|
|
||||||
label={t(`common.auth.roles.${role}`, { defaultValue: role })}
|
|
||||||
value={String(count)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{unverified > 0 && (
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.team.unverified")}
|
|
||||||
value={String(unverified)}
|
|
||||||
color={theme.palette.warning.main}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardMeterRow, CardRow } from "../CardPrimitives";
|
|
||||||
|
|
||||||
export interface BreakdownBucket {
|
|
||||||
/** Stable key and displayed label. */
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
total: number;
|
|
||||||
down: number;
|
|
||||||
/** Where the row links to, if anywhere. */
|
|
||||||
to?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "How the fleet splits by X" — one proportional row per bucket, flagging any
|
|
||||||
* bucket with something down. Monitors by type and Monitors by group are the
|
|
||||||
* same card over different keys, so they share this shell.
|
|
||||||
*/
|
|
||||||
export const BreakdownCard = ({
|
|
||||||
titleKey,
|
|
||||||
emptyKey,
|
|
||||||
to,
|
|
||||||
buckets,
|
|
||||||
isLoading,
|
|
||||||
isStale,
|
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
titleKey: string;
|
|
||||||
emptyKey: string;
|
|
||||||
to?: string;
|
|
||||||
buckets: BreakdownBucket[];
|
|
||||||
isLoading?: boolean;
|
|
||||||
isStale?: boolean;
|
|
||||||
error?: unknown;
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const max = Math.max(0, ...buckets.map((bucket) => bucket.total));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t(titleKey)}
|
|
||||||
to={to}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
isStale={isStale}
|
|
||||||
>
|
|
||||||
{buckets.length === 0 ? (
|
|
||||||
<CardMessage text={t(emptyKey)} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{buckets.map((bucket) => (
|
|
||||||
<CardRow
|
|
||||||
key={bucket.key}
|
|
||||||
to={bucket.to}
|
|
||||||
>
|
|
||||||
<CardMeterRow
|
|
||||||
label={bucket.label}
|
|
||||||
value={bucket.total}
|
|
||||||
max={max}
|
|
||||||
color={
|
|
||||||
bucket.down > 0 ? theme.palette.error.main : theme.palette.primary.main
|
|
||||||
}
|
|
||||||
trailing={
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{bucket.down > 0 && (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.monitorsByType.down", {
|
|
||||||
count: bucket.down,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{bucket.total}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardBar, CardRow, CardRowLabel } from "../CardPrimitives";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import { latestCheck } from "../../checks";
|
|
||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
// <60% fine, 60-85% warm, above that hot.
|
|
||||||
const WARM = 60;
|
|
||||||
const HOT = 85;
|
|
||||||
|
|
||||||
interface ServerUsage {
|
|
||||||
monitor: Monitor;
|
|
||||||
cpu: number | null;
|
|
||||||
memory: number | null;
|
|
||||||
disk: number | null;
|
|
||||||
peak: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Metric = ({ label, value }: { label: string; value: number | null }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const color =
|
|
||||||
value === null
|
|
||||||
? theme.palette.text.secondary
|
|
||||||
: value > HOT
|
|
||||||
? theme.palette.error.main
|
|
||||||
: value > WARM
|
|
||||||
? theme.palette.warning.main
|
|
||||||
: theme.palette.primary.main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>{label}</Typography>
|
|
||||||
<Typography color={color}>
|
|
||||||
{/* A down server reports nothing — show a dash, never 0%. */}
|
|
||||||
{value === null ? "—" : `${value.toFixed(1)}%`}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<CardBar
|
|
||||||
value={value ?? 0}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BusiestServersCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const servers = useMemo<ServerUsage[]>(() => {
|
|
||||||
return (data?.monitors ?? [])
|
|
||||||
.filter((monitor) => monitor.type === "hardware")
|
|
||||||
.map((monitor) => {
|
|
||||||
const latest = latestCheck(monitor);
|
|
||||||
// snake_case here, unlike camelCase in HardwareCheckStats, and
|
|
||||||
// these are 0-1 fractions, so scale to percent.
|
|
||||||
const toPercent = (value: number | undefined) =>
|
|
||||||
typeof value === "number" ? value * 100 : null;
|
|
||||||
const cpu = toPercent(latest?.cpu?.usage_percent);
|
|
||||||
const memory = toPercent(latest?.memory?.usage_percent);
|
|
||||||
const disks = (latest?.disk ?? [])
|
|
||||||
.map((entry) => entry.usage_percent)
|
|
||||||
.filter((value): value is number => typeof value === "number");
|
|
||||||
const disk = disks.length > 0 ? Math.max(...disks) * 100 : null;
|
|
||||||
return {
|
|
||||||
monitor,
|
|
||||||
cpu,
|
|
||||||
memory,
|
|
||||||
disk,
|
|
||||||
peak: Math.max(cpu ?? 0, memory ?? 0, disk ?? 0),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.peak - a.peak);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.busiestServers.title")}
|
|
||||||
to="/infrastructure"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{servers.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.busiestServers.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
|
||||||
{servers.map(({ monitor, cpu, memory, disk }) => (
|
|
||||||
<CardRow
|
|
||||||
key={monitor.id}
|
|
||||||
to={`/infrastructure/${monitor.id}`}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<CardRowLabel primary={monitor.name} />
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
|
||||||
>
|
|
||||||
<Metric
|
|
||||||
label={t("pages.dashboard.cards.busiestServers.cpu")}
|
|
||||||
value={cpu}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label={t("pages.dashboard.cards.busiestServers.memory")}
|
|
||||||
value={memory}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label={t("pages.dashboard.cards.busiestServers.disk")}
|
|
||||||
value={disk}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard } from "../DashboardCard";
|
|
||||||
import { CardBar, CardStatLine } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import { REFRESH_INTERVAL_MS } from "../../cards";
|
|
||||||
|
|
||||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
|
||||||
|
|
||||||
const BYTES_PER_MB = 1024 * 1024;
|
|
||||||
|
|
||||||
// Banding from CARD-SPECS: warm at 50ms of event-loop delay, hot at 200ms;
|
|
||||||
// heap warm past 80% of the V8 limit.
|
|
||||||
const EVENT_LOOP_WARM_MS = 50;
|
|
||||||
const EVENT_LOOP_HOT_MS = 200;
|
|
||||||
const HEAP_WARM_RATIO = 0.8;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one card that is never empty — it reports on Checkmate itself, so it
|
|
||||||
* works on a fresh install with zero monitors. Admin only.
|
|
||||||
*/
|
|
||||||
export const CheckmateServerCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<Diagnostics>(
|
|
||||||
"/diagnostic/system",
|
|
||||||
{},
|
|
||||||
{ refreshInterval: REFRESH_INTERVAL_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const usedHeap = data?.v8HeapStats.usedHeapSizeBytes ?? 0;
|
|
||||||
const heapLimit = data?.v8HeapStats.heapSizeLimitBytes ?? 0;
|
|
||||||
const heapRatio = heapLimit > 0 ? usedHeap / heapLimit : 0;
|
|
||||||
const heapColor =
|
|
||||||
heapRatio > HEAP_WARM_RATIO ? theme.palette.warning.main : theme.palette.primary.main;
|
|
||||||
|
|
||||||
const eventLoopMs = data?.eventLoopDelayMs ?? 0;
|
|
||||||
const eventLoopColor =
|
|
||||||
eventLoopMs > EVENT_LOOP_HOT_MS
|
|
||||||
? theme.palette.error.main
|
|
||||||
: eventLoopMs > EVENT_LOOP_WARM_MS
|
|
||||||
? theme.palette.warning.main
|
|
||||||
: theme.palette.text.primary;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.checkmateServer.title")}
|
|
||||||
to="/logs"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.memory")}
|
|
||||||
value={`${Math.round(usedHeap / BYTES_PER_MB)} / ${Math.round(
|
|
||||||
heapLimit / BYTES_PER_MB
|
|
||||||
)} MB`}
|
|
||||||
color={heapColor}
|
|
||||||
/>
|
|
||||||
<CardBar
|
|
||||||
value={heapRatio * 100}
|
|
||||||
color={heapColor}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.eventLoop")}
|
|
||||||
value={`${eventLoopMs.toFixed(1)} ms`}
|
|
||||||
color={eventLoopColor}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.uptime")}
|
|
||||||
value={formatDuration(data.uptimeMs)}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.checkmateServer.database")}
|
|
||||||
value={t("pages.dashboard.cards.checkmateServer.readsWrites", {
|
|
||||||
reads: data.mongoStats.readsPerSecond.toFixed(1),
|
|
||||||
writes: data.mongoStats.writesPerSecond.toFixed(1),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { getStatusColor, getMonitorPath } from "@/Utils/MonitorUtils";
|
|
||||||
import { formatStatusCode } from "@/Utils/statusCode";
|
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardRow } from "../CardPrimitives";
|
|
||||||
import { Sparkline } from "../Sparkline";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import { latestCheck } from "../../checks";
|
|
||||||
|
|
||||||
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
const SPARKLINE_WIDTH = 80;
|
|
||||||
|
|
||||||
const STATUS_RANK: Partial<Record<MonitorStatus, number>> = {
|
|
||||||
down: 1,
|
|
||||||
breached: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const isProblem = (monitor: Monitor) => STATUS_RANK[monitor.status] !== undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long the monitor has been down, from the oldest consecutive failing
|
|
||||||
* check. recentChecks is oldest-first, so the run is walked back from the end.
|
|
||||||
*/
|
|
||||||
const downSince = (monitor: Monitor): string | null => {
|
|
||||||
const checks = monitor.recentChecks ?? [];
|
|
||||||
let oldestFailing: string | null = null;
|
|
||||||
for (let index = checks.length - 1; index >= 0; index -= 1) {
|
|
||||||
if (checks[index].status !== false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
oldestFailing = checks[index].createdAt;
|
|
||||||
}
|
|
||||||
return oldestFailing;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CurrentlyDownCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const problems = useMemo(() => {
|
|
||||||
const monitors = data?.monitors ?? [];
|
|
||||||
return monitors
|
|
||||||
.filter(isProblem)
|
|
||||||
.map((monitor) => ({ monitor, since: downSince(monitor) }))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const rank =
|
|
||||||
(STATUS_RANK[a.monitor.status] ?? 99) - (STATUS_RANK[b.monitor.status] ?? 99);
|
|
||||||
if (rank !== 0) {
|
|
||||||
return rank;
|
|
||||||
}
|
|
||||||
// Tie-break on longest downtime: the older timestamp ranks first.
|
|
||||||
return new Date(a.since ?? 0).getTime() - new Date(b.since ?? 0).getTime();
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.currentlyDown.title")}
|
|
||||||
to="/incidents"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
problems.length > 0 ? (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.currentlyDown.count", {
|
|
||||||
count: problems.length,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{problems.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.currentlyDown.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{problems.map(({ monitor, since }) => (
|
|
||||||
<CardRow
|
|
||||||
key={monitor.id}
|
|
||||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={getStatusColor(monitor.status, theme)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<Stack
|
|
||||||
flex={1}
|
|
||||||
minWidth={0}
|
|
||||||
gap={theme.spacing(LAYOUT.XXS)}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{monitor.name}
|
|
||||||
</Typography>
|
|
||||||
{since && (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{formatDuration(Date.now() - new Date(since).getTime())}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
{/* Not StatusCodeLabel: its Tooltip is focusable, and a
|
|
||||||
focusable element inside the row's link is unreachable
|
|
||||||
by keyboard. */}
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{monitor.type} ·{" "}
|
|
||||||
{formatStatusCode(latestCheck(monitor)?.statusCode, t)}
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
width={SPARKLINE_WIDTH}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
<Sparkline
|
|
||||||
checks={monitor.recentChecks ?? []}
|
|
||||||
color={theme.palette.error.main}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardBar, CardFigure } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import type { ChecksSummary } from "@/Types/Check";
|
|
||||||
|
|
||||||
// Day only. `dateRange=week` returns figures identical to `day`, so a range
|
|
||||||
// selector here would silently lie.
|
|
||||||
const SUMMARY_URL = "/checks/team/summary?dateRange=day";
|
|
||||||
|
|
||||||
export const FailedChecksCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<ChecksSummary>(SUMMARY_URL);
|
|
||||||
|
|
||||||
const total = data?.totalChecks ?? 0;
|
|
||||||
const failed = data?.downChecks ?? 0;
|
|
||||||
const percentage = total > 0 ? (failed / total) * 100 : 0;
|
|
||||||
const color = percentage > 10 ? theme.palette.error.main : theme.palette.success.main;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.failedChecks.title")}
|
|
||||||
to="/checks"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{total === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.failedChecks.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardFigure
|
|
||||||
value={`${percentage.toFixed(1)}%`}
|
|
||||||
caption={t("pages.dashboard.cards.failedChecks.ratio", {
|
|
||||||
failed: failed.toLocaleString(),
|
|
||||||
total: total.toLocaleString(),
|
|
||||||
})}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
<CardBar
|
|
||||||
value={percentage}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
|
||||||
import { getStatusColor } from "@/Utils/MonitorUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardSegmentedBar, type BarSegment } from "../CardPrimitives";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import type { MonitorStatus, MonitorsSummary } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
// Every state the summary reports, ordered worst first so the bar reads
|
|
||||||
// problems-to-healthy left to right and the chips below list what is wrong
|
|
||||||
// before what is merely idle. `up` is handled separately as the headline.
|
|
||||||
const STATES: { key: string; status: MonitorStatus; field: keyof MonitorsSummary }[] = [
|
|
||||||
{ key: "down", status: "down", field: "downMonitors" },
|
|
||||||
{ key: "breached", status: "breached", field: "breachedMonitors" },
|
|
||||||
{ key: "initializing", status: "initializing", field: "initializingMonitors" },
|
|
||||||
{ key: "maintenance", status: "maintenance", field: "maintenanceMonitors" },
|
|
||||||
{ key: "paused", status: "paused", field: "pausedMonitors" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MonitorStatusCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
const summary = data?.summary ?? null;
|
|
||||||
|
|
||||||
// Only states that actually have monitors are shown. Padding the card with
|
|
||||||
// zeros says nothing, and a healthy fleet should read as calm rather than as
|
|
||||||
// a row of empty counters.
|
|
||||||
const present = summary
|
|
||||||
? STATES.map((state) => ({ ...state, count: summary[state.field] })).filter(
|
|
||||||
(state) => state.count > 0
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const segments: BarSegment[] = summary
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "up",
|
|
||||||
value: summary.upMonitors,
|
|
||||||
color: getStatusColor("up", theme),
|
|
||||||
label: `${t("pages.common.monitors.status.up")} — ${summary.upMonitors}`,
|
|
||||||
},
|
|
||||||
...present.map((state) => ({
|
|
||||||
key: state.key,
|
|
||||||
value: state.count,
|
|
||||||
color: getStatusColor(state.status, theme),
|
|
||||||
label: `${t(`pages.common.monitors.status.${state.key}`)} — ${state.count}`,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.monitorStatus.title")}
|
|
||||||
to="/uptime"
|
|
||||||
isLoading={isLoading && !summary}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{!summary || summary.totalMonitors === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.monitorStatus.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="baseline"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
fontSize={typographyLevels.xxl}
|
|
||||||
fontWeight={300}
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
lineHeight={1.1}
|
|
||||||
>
|
|
||||||
{summary.upMonitors}
|
|
||||||
</Typography>
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{t("pages.dashboard.cards.monitorStatus.upOfTotal", {
|
|
||||||
total: summary.totalMonitors,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<CardSegmentedBar segments={segments} />
|
|
||||||
</Stack>
|
|
||||||
{present.length > 0 && (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
flexWrap="wrap"
|
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
|
||||||
>
|
|
||||||
{present.map((state) => (
|
|
||||||
<Stack
|
|
||||||
key={state.key}
|
|
||||||
component={RouterLink}
|
|
||||||
// Plain /uptime: the monitors list does not read query
|
|
||||||
// params, so a ?status= filter would be silently dropped
|
|
||||||
// and the link would lie about where it goes.
|
|
||||||
to="/uptime"
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.XS)}
|
|
||||||
px={theme.spacing(LAYOUT.XS)}
|
|
||||||
py={theme.spacing(LAYOUT.XXS)}
|
|
||||||
mx={`-${theme.spacing(LAYOUT.XS)}`}
|
|
||||||
borderRadius={theme.shape.borderRadius}
|
|
||||||
sx={{
|
|
||||||
textDecoration: "none",
|
|
||||||
"&:hover": { backgroundColor: theme.palette.action.hover },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={getStatusColor(state.status, theme)}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{t(`pages.common.monitors.status.${state.key}`)}
|
|
||||||
</Typography>
|
|
||||||
<Typography color={theme.palette.text.primary}>
|
|
||||||
{state.count}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
export const MonitorsByGroupCard = () => {
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
// `group` is a plain string | null on the monitor — there is no groups
|
|
||||||
// endpoint, so the set is whatever monitors actually declare.
|
|
||||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
|
||||||
const byGroup = new Map<string, BreakdownBucket>();
|
|
||||||
for (const monitor of data?.monitors ?? []) {
|
|
||||||
if (!monitor.group) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bucket = byGroup.get(monitor.group) ?? {
|
|
||||||
key: monitor.group,
|
|
||||||
label: monitor.group,
|
|
||||||
total: 0,
|
|
||||||
down: 0,
|
|
||||||
};
|
|
||||||
bucket.total += 1;
|
|
||||||
if (monitor.status === "down" || monitor.status === "breached") {
|
|
||||||
bucket.down += 1;
|
|
||||||
}
|
|
||||||
byGroup.set(monitor.group, bucket);
|
|
||||||
}
|
|
||||||
return [...byGroup.values()].sort((a, b) => b.total - a.total);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreakdownCard
|
|
||||||
titleKey="pages.dashboard.cards.monitorsByGroup.title"
|
|
||||||
emptyKey="pages.dashboard.cards.monitorsByGroup.empty"
|
|
||||||
to="/uptime"
|
|
||||||
buckets={buckets}
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { getMonitorPath } from "@/Utils/MonitorUtils";
|
|
||||||
import { BreakdownCard, type BreakdownBucket } from "./BreakdownCard";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import type { MonitorType } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
export const MonitorsByTypeCard = () => {
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const buckets = useMemo<BreakdownBucket[]>(() => {
|
|
||||||
const byType = new Map<MonitorType, BreakdownBucket>();
|
|
||||||
for (const monitor of data?.monitors ?? []) {
|
|
||||||
const bucket = byType.get(monitor.type) ?? {
|
|
||||||
key: monitor.type,
|
|
||||||
label: monitor.type,
|
|
||||||
total: 0,
|
|
||||||
down: 0,
|
|
||||||
to: `/${getMonitorPath(monitor.type)}`,
|
|
||||||
};
|
|
||||||
bucket.total += 1;
|
|
||||||
if (monitor.status === "down" || monitor.status === "breached") {
|
|
||||||
bucket.down += 1;
|
|
||||||
}
|
|
||||||
byType.set(monitor.type, bucket);
|
|
||||||
}
|
|
||||||
return [...byType.values()].sort((a, b) => b.total - a.total);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreakdownCard
|
|
||||||
titleKey="pages.dashboard.cards.monitorsByType.title"
|
|
||||||
emptyKey="pages.dashboard.cards.monitorsByType.empty"
|
|
||||||
to="/uptime"
|
|
||||||
buckets={buckets}
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { getMonitorPath, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardMeterRow, CardRow, CardRowLabel } from "../CardPrimitives";
|
|
||||||
import { useMonitors } from "../../useDashboardData";
|
|
||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
const TOP_N = 5;
|
|
||||||
// A ranking of one is not a ranking.
|
|
||||||
const MIN_RANKABLE = 2;
|
|
||||||
|
|
||||||
const averageResponseTime = (monitor: Monitor): number | null => {
|
|
||||||
const times = (monitor.recentChecks ?? [])
|
|
||||||
.map((check) => check.responseTime)
|
|
||||||
.filter((value): value is number => typeof value === "number");
|
|
||||||
if (times.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return times.reduce((sum, value) => sum + value, 0) / times.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Header reads "top 5 of 12" so a truncated ranking says so. */
|
|
||||||
const RankingCount = ({ shown, total }: { shown: number; total: number }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Typography color={theme.palette.text.secondary}>
|
|
||||||
{t("pages.dashboard.cards.ranking.topOf", { shown, total })}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SlowestMonitorsCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const ranked = useMemo(() => {
|
|
||||||
return (data?.monitors ?? [])
|
|
||||||
.filter((monitor) => monitor.status !== "paused")
|
|
||||||
.map((monitor) => ({ monitor, average: averageResponseTime(monitor) }))
|
|
||||||
.filter(
|
|
||||||
(entry): entry is { monitor: Monitor; average: number } => entry.average !== null
|
|
||||||
)
|
|
||||||
.sort((a, b) => b.average - a.average);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const slowest = ranked[0]?.average ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.slowestMonitors.title")}
|
|
||||||
to="/uptime"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
ranked.length >= MIN_RANKABLE ? (
|
|
||||||
<RankingCount
|
|
||||||
shown={Math.min(TOP_N, ranked.length)}
|
|
||||||
total={ranked.length}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ranked.length < MIN_RANKABLE ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{ranked.slice(0, TOP_N).map(({ monitor, average }) => (
|
|
||||||
<CardRow
|
|
||||||
key={monitor.id}
|
|
||||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
|
||||||
>
|
|
||||||
<CardMeterRow
|
|
||||||
label={<CardRowLabel primary={monitor.name} />}
|
|
||||||
value={average}
|
|
||||||
max={slowest}
|
|
||||||
color={theme.palette.primary.main}
|
|
||||||
trailing={
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{Math.round(average)} ms
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LowestUptimeCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useMonitors();
|
|
||||||
|
|
||||||
const ranked = useMemo(() => {
|
|
||||||
return (
|
|
||||||
(data?.monitors ?? [])
|
|
||||||
// Paused monitors keep whatever uptime they had, or zero — a paused
|
|
||||||
// ping monitor reads 0% and would rank beside a genuinely down one.
|
|
||||||
// Neither is a live problem, so neither belongs in a worst-uptime list.
|
|
||||||
.filter((monitor) => monitor.status !== "paused")
|
|
||||||
.filter(
|
|
||||||
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
|
||||||
typeof monitor.uptimePercentage === "number"
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.uptimePercentage - b.uptimePercentage)
|
|
||||||
);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.lowestUptime.title")}
|
|
||||||
to="/uptime"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
ranked.length >= MIN_RANKABLE ? (
|
|
||||||
<RankingCount
|
|
||||||
shown={Math.min(TOP_N, ranked.length)}
|
|
||||||
total={ranked.length}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{ranked.length < MIN_RANKABLE ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.ranking.notEnough")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{ranked.slice(0, TOP_N).map((monitor) => {
|
|
||||||
// uptimePercentage is a 0-1 fraction, not 0-100.
|
|
||||||
const percentage = monitor.uptimePercentage * 100;
|
|
||||||
const palette = getUptimePercentageColor(monitor.uptimePercentage);
|
|
||||||
return (
|
|
||||||
<CardRow
|
|
||||||
key={monitor.id}
|
|
||||||
to={`/${getMonitorPath(monitor.type)}/${monitor.id}`}
|
|
||||||
>
|
|
||||||
<CardMeterRow
|
|
||||||
label={<CardRowLabel primary={monitor.name} />}
|
|
||||||
// The card ranks worst uptime first, so the bar shows the
|
|
||||||
// shortfall - the worst monitor gets the longest bar.
|
|
||||||
value={100 - percentage}
|
|
||||||
color={theme.palette[palette].main}
|
|
||||||
trailing={
|
|
||||||
<Typography
|
|
||||||
color={theme.palette[palette].main}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{percentage.toFixed(1)}%
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import { CardRow, CardRowLabel } from "../CardPrimitives";
|
|
||||||
|
|
||||||
import type { IncidentSummary, IncidentSummaryItem } from "@/Types/Incident";
|
|
||||||
|
|
||||||
const MAX_ROWS = 5;
|
|
||||||
|
|
||||||
/** Open incidents have no end time; closed ones report how long they ran. */
|
|
||||||
const incidentDuration = (incident: IncidentSummaryItem): string | null => {
|
|
||||||
if (!incident.endTime) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const ms =
|
|
||||||
new Date(incident.endTime).getTime() - new Date(incident.startTime).getTime();
|
|
||||||
return ms > 0 ? formatDuration(ms) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecentIncidentsCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
|
||||||
"/incidents/team/summary"
|
|
||||||
);
|
|
||||||
|
|
||||||
const incidents = (data?.latestIncidents ?? []).slice(0, MAX_ROWS);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.recentIncidents.title")}
|
|
||||||
to="/incidents"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
action={
|
|
||||||
data && data.totalActive > 0 ? (
|
|
||||||
<Typography color={theme.palette.error.main}>
|
|
||||||
{t("pages.dashboard.cards.recentIncidents.active", {
|
|
||||||
count: data.totalActive,
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{incidents.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.recentIncidents.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{incidents.map((incident) => {
|
|
||||||
const isOpen = incident.status === true;
|
|
||||||
const duration = incidentDuration(incident);
|
|
||||||
return (
|
|
||||||
<CardRow
|
|
||||||
key={incident.id}
|
|
||||||
to="/incidents"
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={isOpen ? theme.palette.error.light : theme.palette.success.light}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<CardRowLabel
|
|
||||||
primary={
|
|
||||||
incident.monitorName ??
|
|
||||||
t("pages.dashboard.cards.recentIncidents.unknownMonitor")
|
|
||||||
}
|
|
||||||
secondary={formatTimestamp(incident.startTime)}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{isOpen
|
|
||||||
? t("pages.dashboard.cards.recentIncidents.ongoing")
|
|
||||||
: (duration ?? "—")}
|
|
||||||
</Typography>
|
|
||||||
{incident.resolutionType === "manual" && (
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.warning.main}
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{t("pages.dashboard.cards.recentIncidents.manual")}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</CardRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { Dot } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { formatTimestamp } from "@/Utils/TimeUtils";
|
|
||||||
import { DashboardCard, CardMessage } from "../DashboardCard";
|
|
||||||
import {
|
|
||||||
CardFigure,
|
|
||||||
CardRow,
|
|
||||||
CardRowLabel,
|
|
||||||
CardSegmentedBar,
|
|
||||||
CardStatLine,
|
|
||||||
} from "../CardPrimitives";
|
|
||||||
|
|
||||||
import type { IncidentSummary } from "@/Types/Incident";
|
|
||||||
import type { Notification } from "@/Types/Notification";
|
|
||||||
import type { MaintenanceWindow } from "@/Types/MaintenanceWindow";
|
|
||||||
import type { StatusPage } from "@/Types/StatusPage";
|
|
||||||
|
|
||||||
/** Free when Recent incidents is present — same summary call, shared by SWR. */
|
|
||||||
export const IncidentStatsCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<IncidentSummary>(
|
|
||||||
"/incidents/team/summary"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.incidentStats.title")}
|
|
||||||
to="/incidents"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{data && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
|
||||||
{/* Open now leads, paired with the all-time total for context: two
|
|
||||||
open means something different against 12 incidents than 183. */}
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap={theme.spacing(LAYOUT.XXL)}
|
|
||||||
>
|
|
||||||
<CardFigure
|
|
||||||
value={String(data.totalActive)}
|
|
||||||
caption={t("pages.dashboard.cards.incidentStats.openNow")}
|
|
||||||
color={
|
|
||||||
data.totalActive > 0
|
|
||||||
? theme.palette.error.main
|
|
||||||
: theme.palette.success.main
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardFigure
|
|
||||||
value={data.total.toLocaleString()}
|
|
||||||
caption={t("pages.dashboard.cards.incidentStats.total")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
{/* How much of the fleet heals itself — the automatic/manual split
|
|
||||||
is already computed server-side and said nothing on the card
|
|
||||||
before. A high manual share means people are doing the work. */}
|
|
||||||
{data.totalAutomaticResolutions + data.totalManualResolutions > 0 && (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
<CardSegmentedBar
|
|
||||||
segments={[
|
|
||||||
{
|
|
||||||
key: "automatic",
|
|
||||||
value: data.totalAutomaticResolutions,
|
|
||||||
color: theme.palette.success.main,
|
|
||||||
label: `${t("pages.dashboard.cards.incidentStats.automatic")} — ${data.totalAutomaticResolutions}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "manual",
|
|
||||||
value: data.totalManualResolutions,
|
|
||||||
color: theme.palette.warning.main,
|
|
||||||
label: `${t("pages.dashboard.cards.incidentStats.manual")} — ${data.totalManualResolutions}`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.incidentStats.resolved")}
|
|
||||||
value={t("pages.dashboard.cards.incidentStats.autoManual", {
|
|
||||||
automatic: data.totalAutomaticResolutions,
|
|
||||||
manual: data.totalManualResolutions,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.incidentStats.avgResolution")}
|
|
||||||
value={t("pages.dashboard.cards.incidentStats.hours", {
|
|
||||||
hours: data.avgResolutionTimeHours.toFixed(1),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
{data.topMonitor?.monitorName && (
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.incidentStats.mostIncidents")}
|
|
||||||
value={`${data.topMonitor.monitorName} (${data.topMonitor.incidentCount})`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NotificationChannelsCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } =
|
|
||||||
useGet<Notification[]>("/notifications/team");
|
|
||||||
|
|
||||||
// The response also carries accessToken and accountSid. Only `type` is read
|
|
||||||
// here — credential fields must never reach the DOM.
|
|
||||||
const byType = useMemo(() => {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const notification of data ?? []) {
|
|
||||||
counts.set(notification.type, (counts.get(notification.type) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.notificationChannels.title")}
|
|
||||||
to="/notifications"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{byType.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.notificationChannels.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{byType.map(([type, count]) => (
|
|
||||||
<CardStatLine
|
|
||||||
key={type}
|
|
||||||
label={type}
|
|
||||||
value={String(count)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MaintenanceCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } = useGet<{
|
|
||||||
maintenanceWindows: MaintenanceWindow[];
|
|
||||||
maintenanceWindowCount: number;
|
|
||||||
}>("/maintenance-window/team?page=0&rowsPerPage=0");
|
|
||||||
|
|
||||||
// `active` is a user on/off flag, not "running now" — compare the window
|
|
||||||
// client-side. repeat > 0 means recurring, so `end` may sit in the past for
|
|
||||||
// a window that is currently live.
|
|
||||||
const { running, next } = useMemo(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const windows = (data?.maintenanceWindows ?? []).filter((entry) => entry.active);
|
|
||||||
const live = windows.filter((entry) => {
|
|
||||||
const start = new Date(entry.start).getTime();
|
|
||||||
const end = new Date(entry.end).getTime();
|
|
||||||
return start <= now && (now <= end || entry.repeat > 0);
|
|
||||||
});
|
|
||||||
const upcoming = windows
|
|
||||||
.filter((entry) => new Date(entry.start).getTime() > now)
|
|
||||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
||||||
return { running: live.length, next: upcoming[0] ?? null };
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.maintenance.title")}
|
|
||||||
to="/maintenance"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{!data || data.maintenanceWindows.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.maintenance.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
<CardFigure
|
|
||||||
value={String(running)}
|
|
||||||
caption={t("pages.dashboard.cards.maintenance.runningNow")}
|
|
||||||
/>
|
|
||||||
{next && (
|
|
||||||
<CardStatLine
|
|
||||||
label={t("pages.dashboard.cards.maintenance.next")}
|
|
||||||
value={formatTimestamp(next.start)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StatusPagesCard = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading, isValidating, error } =
|
|
||||||
useGet<StatusPage[]>("/status-page/team");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
title={t("pages.dashboard.cards.statusPages.title")}
|
|
||||||
to="/status"
|
|
||||||
isLoading={isLoading && !data}
|
|
||||||
error={error}
|
|
||||||
isStale={isValidating && Boolean(data)}
|
|
||||||
>
|
|
||||||
{!data || data.length === 0 ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.cards.statusPages.empty")} />
|
|
||||||
) : (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.XS)}>
|
|
||||||
{data.map((page) => (
|
|
||||||
<CardRow
|
|
||||||
key={page.id}
|
|
||||||
to={`/status/${page.url}`}
|
|
||||||
>
|
|
||||||
<Dot
|
|
||||||
color={
|
|
||||||
page.isPublished
|
|
||||||
? theme.palette.success.light
|
|
||||||
: theme.palette.text.secondary
|
|
||||||
}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<CardRowLabel
|
|
||||||
primary={page.companyName}
|
|
||||||
secondary={t("pages.dashboard.cards.statusPages.monitorCount", {
|
|
||||||
count: page.monitors.length,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
color={theme.palette.text.secondary}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
{page.isPublished
|
|
||||||
? t("pages.dashboard.cards.statusPages.published")
|
|
||||||
: t("pages.dashboard.cards.statusPages.draft")}
|
|
||||||
</Typography>
|
|
||||||
</CardRow>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</DashboardCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
|
||||||
import Skeleton from "@mui/material/Skeleton";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { BaseBox } from "@/Components/design-elements";
|
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
|
||||||
import { useCardSlot } from "./CardSlotContext";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
// Control height plus the header's vertical padding, so a card with no action
|
|
||||||
// still lines up with one that has a button in its header.
|
|
||||||
const HEADER_MIN_HEIGHT = 44;
|
|
||||||
const SKELETON_ROW_HEIGHT = 20;
|
|
||||||
|
|
||||||
interface DashboardCardProps {
|
|
||||||
title: string;
|
|
||||||
/** Where the title links to — the page this card belongs to. */
|
|
||||||
to?: string;
|
|
||||||
/** Rendered at the right of the header: a count, an average, a filter. */
|
|
||||||
action?: ReactNode;
|
|
||||||
isLoading?: boolean;
|
|
||||||
error?: unknown;
|
|
||||||
/** Dimmed rather than blanked while a refresh is in flight over stale data. */
|
|
||||||
isStale?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DashboardCard = ({
|
|
||||||
title,
|
|
||||||
to,
|
|
||||||
action,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
isStale,
|
|
||||||
children,
|
|
||||||
}: DashboardCardProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const slot = useCardSlot();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseBox
|
|
||||||
display="flex"
|
|
||||||
flexDirection="column"
|
|
||||||
height="100%"
|
|
||||||
sx={{
|
|
||||||
overflow: "hidden",
|
|
||||||
"&:hover .dashboard-card-slot, &:has(:focus-visible) .dashboard-card-slot": {
|
|
||||||
opacity: 1,
|
|
||||||
pointerEvents: "auto",
|
|
||||||
},
|
|
||||||
"&:hover .dashboard-card-action, &:has(:focus-visible) .dashboard-card-action": {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
px={theme.spacing(LAYOUT.MD)}
|
|
||||||
py={theme.spacing(LAYOUT.SM)}
|
|
||||||
borderBottom={`1px solid ${theme.palette.divider}`}
|
|
||||||
minHeight={HEADER_MIN_HEIGHT}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
{...(to ? { component: RouterLink, to } : {})}
|
|
||||||
fontWeight={500}
|
|
||||||
color={theme.palette.text.primary}
|
|
||||||
sx={{
|
|
||||||
textDecoration: "none",
|
|
||||||
...(to && {
|
|
||||||
"&:hover": { color: theme.palette.primary.main },
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
flexShrink={0}
|
|
||||||
position="relative"
|
|
||||||
>
|
|
||||||
{isStale && (
|
|
||||||
<CircularProgress
|
|
||||||
size={16}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{action && (
|
|
||||||
<Box
|
|
||||||
className="dashboard-card-action"
|
|
||||||
sx={{ transition: "opacity 0.15s ease" }}
|
|
||||||
>
|
|
||||||
{action}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{slot && (
|
|
||||||
<Box
|
|
||||||
className="dashboard-card-slot"
|
|
||||||
position="absolute"
|
|
||||||
right={0}
|
|
||||||
sx={{
|
|
||||||
opacity: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
transition: "opacity 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{slot}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Box
|
|
||||||
flex={1}
|
|
||||||
p={theme.spacing(LAYOUT.MD)}
|
|
||||||
sx={{
|
|
||||||
...(isStale && { opacity: 0.5, transition: "opacity 0.2s ease" }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<CardSkeleton />
|
|
||||||
) : error ? (
|
|
||||||
<CardMessage text={t("pages.dashboard.card.error")} />
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</BaseBox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Placeholder rows, sized to the row list most cards render. */
|
|
||||||
export const CardSkeleton = ({ rows = 3 }: { rows?: number }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
|
||||||
{Array.from({ length: rows }).map((_, index) => (
|
|
||||||
<Skeleton
|
|
||||||
key={index}
|
|
||||||
variant="rounded"
|
|
||||||
height={SKELETON_ROW_HEIGHT}
|
|
||||||
width={index === rows - 1 ? "60%" : "100%"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The quiet one-line state a healthy card collapses to. */
|
|
||||||
export const CardMessage = ({ text, action }: { text: string; action?: ReactNode }) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={theme.spacing(LAYOUT.SM)}
|
|
||||||
>
|
|
||||||
<Typography color={theme.palette.text.secondary}>{text}</Typography>
|
|
||||||
{action}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import Box from "@mui/material/Box";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
|
||||||
|
|
||||||
const VIEWBOX_WIDTH = 100;
|
|
||||||
// The svg renders at its viewBox height, so one constant serves both.
|
|
||||||
const VIEWBOX_HEIGHT = 24;
|
|
||||||
// Matches the 1.5 stroke the shared Icon component uses.
|
|
||||||
const STROKE_WIDTH = 1.5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response times as a bare polyline. Deliberately axis-free and label-free —
|
|
||||||
* at this size the shape is the only readable signal.
|
|
||||||
*
|
|
||||||
* `checks` arrives oldest-first, which is already left-to-right reading order.
|
|
||||||
*/
|
|
||||||
export const Sparkline = ({
|
|
||||||
checks,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
checks: CheckSnapshot[];
|
|
||||||
color: string;
|
|
||||||
}) => {
|
|
||||||
const points = useMemo(() => {
|
|
||||||
const values = checks
|
|
||||||
.map((check) => check.responseTime)
|
|
||||||
.filter((value): value is number => typeof value === "number");
|
|
||||||
|
|
||||||
if (values.length < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const span = max - min || 1;
|
|
||||||
|
|
||||||
return values
|
|
||||||
.map((value, index) => {
|
|
||||||
const x = (index / (values.length - 1)) * VIEWBOX_WIDTH;
|
|
||||||
const y = VIEWBOX_HEIGHT - ((value - min) / span) * VIEWBOX_HEIGHT;
|
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
}, [checks]);
|
|
||||||
|
|
||||||
if (!points) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="svg"
|
|
||||||
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
width="100%"
|
|
||||||
height={VIEWBOX_HEIGHT}
|
|
||||||
display="block"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
points={points}
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={STROKE_WIDTH}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeLinecap="round"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import type { ComponentType } from "react";
|
|
||||||
|
|
||||||
// The 12-column grid the dashboard lays cards out on.
|
|
||||||
//
|
|
||||||
// Every card is currently half-width, so they pair up down the page and the
|
|
||||||
// grid reads as one system. Full width stays in the type for a card whose
|
|
||||||
// content genuinely needs it. Width is declared per card type in code and is
|
|
||||||
// never a user setting.
|
|
||||||
export const CardWidths = [6, 12] as const;
|
|
||||||
export type CardWidth = (typeof CardWidths)[number];
|
|
||||||
|
|
||||||
export const GRID_COLUMNS = 12;
|
|
||||||
|
|
||||||
// Picker groups mirror the sidebar sections: you look for a card where you
|
|
||||||
// look for the feature it belongs to.
|
|
||||||
export const CardGroups = [
|
|
||||||
"uptime",
|
|
||||||
"infrastructure",
|
|
||||||
"incidents",
|
|
||||||
"notifications",
|
|
||||||
"maintenance",
|
|
||||||
"statusPages",
|
|
||||||
"checks",
|
|
||||||
"logs",
|
|
||||||
"settings",
|
|
||||||
] as const;
|
|
||||||
export type CardGroup = (typeof CardGroups)[number];
|
|
||||||
|
|
||||||
// The Containers card (CARD-SPECS.md #11) is deliberately absent: it needs
|
|
||||||
// `containerSummary` on the check snapshot, which arrives with the Docker
|
|
||||||
// monitoring work. Add it here once that lands on develop.
|
|
||||||
export const CardIds = [
|
|
||||||
"monitorStatus",
|
|
||||||
"currentlyDown",
|
|
||||||
"slowestMonitors",
|
|
||||||
"lowestUptime",
|
|
||||||
"monitorsByType",
|
|
||||||
"monitorsByGroup",
|
|
||||||
"recentIncidents",
|
|
||||||
"incidentStats",
|
|
||||||
"notificationChannels",
|
|
||||||
"maintenance",
|
|
||||||
"statusPages",
|
|
||||||
"failedChecks",
|
|
||||||
"busiestServers",
|
|
||||||
"checkmateServer",
|
|
||||||
"checksOnSchedule",
|
|
||||||
"recentErrors",
|
|
||||||
"team",
|
|
||||||
] as const;
|
|
||||||
export type CardId = (typeof CardIds)[number];
|
|
||||||
|
|
||||||
export interface CardDefinition {
|
|
||||||
id: CardId;
|
|
||||||
/** i18n key fragment under pages.dashboard.cards.<key>. */
|
|
||||||
key: string;
|
|
||||||
group: CardGroup;
|
|
||||||
width: CardWidth;
|
|
||||||
/** Hidden from the page and absent from the picker for non-admins. */
|
|
||||||
adminOnly?: boolean;
|
|
||||||
component: ComponentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cards a fresh dashboard starts with. Chosen so a brand new install still
|
|
||||||
// shows something real: Checkmate server works with zero monitors.
|
|
||||||
// Ordered so the declared widths pair into full rows: a card left alone in a
|
|
||||||
// row is stretched to fill it, so 8- and 4-wide cards are kept adjacent.
|
|
||||||
export const DEFAULT_CARD_IDS: readonly CardId[] = [
|
|
||||||
"monitorStatus",
|
|
||||||
"currentlyDown",
|
|
||||||
"failedChecks",
|
|
||||||
"monitorsByType",
|
|
||||||
"recentIncidents",
|
|
||||||
"checkmateServer",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DASHBOARD_CARDS_STORAGE_KEY = "checkmate-dashboard-cards";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll rates, in ms. Monitor and server figures move continuously; the log ring
|
|
||||||
* buffer rotates fast but is expensive to re-read, so it is polled slowly.
|
|
||||||
*/
|
|
||||||
export const REFRESH_INTERVAL_MS = 30000;
|
|
||||||
export const SLOW_REFRESH_INTERVAL_MS = 60000;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `recentChecks` is oldest-first — the server appends each snapshot with
|
|
||||||
* `$push … $slice: -N` — so the newest retained check is the last element.
|
|
||||||
*/
|
|
||||||
export const latestCheck = (monitor: Monitor) =>
|
|
||||||
monitor.recentChecks?.[monitor.recentChecks.length - 1];
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
interface DashboardCardProps {
|
||||||
|
title: string;
|
||||||
|
topRight?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardCard = ({ title, topRight, children }: DashboardCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bgcolor={theme.palette.background.paper}
|
||||||
|
border={1}
|
||||||
|
borderColor={theme.palette.divider}
|
||||||
|
borderRadius={`${theme.shape.borderRadius}px`}
|
||||||
|
padding={theme.spacing(LAYOUT.MD)}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
height="100%"
|
||||||
|
boxSizing="border-box"
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{topRight && (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.s}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{topRight}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Checkbox, Dialog } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
|
interface EditCardsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
visibleCards: Set<DashboardCardKey>;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (visibleCards: Set<DashboardCardKey>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditCardsModal = ({
|
||||||
|
open,
|
||||||
|
visibleCards,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: EditCardsModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
|
||||||
|
new Set(visibleCards)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCardsSelection(new Set(visibleCards));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: DashboardCardKey) => {
|
||||||
|
setCardsSelection((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedCardKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
[...dashboardCardKeys].sort((a, b) =>
|
||||||
|
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
|
||||||
|
),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={t("pages.dashboard.editCardsModal.title")}
|
||||||
|
content={t("pages.dashboard.editCardsModal.description")}
|
||||||
|
onCancel={handleClose}
|
||||||
|
onConfirm={() => onSave(cardsSelection)}
|
||||||
|
confirmText={t("pages.dashboard.editCardsModal.done")}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.editCardsModal.groups.uptime")}
|
||||||
|
</Typography>
|
||||||
|
{sortedCardKeys.map((key: DashboardCardKey) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={key}
|
||||||
|
label={t(`pages.dashboard.cards.${key}`)}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={cardsSelection.has(key)}
|
||||||
|
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
|
||||||
|
onChange={() => handleToggle(key)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
fillRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressBar = ({ fillRatio }: ProgressBarProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const clamped = Math.min(1, Math.max(0, fillRatio));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
height={theme.spacing(LAYOUT.XS)}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.disabledBackground}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
height="100%"
|
||||||
|
width={`${clamped * 100}%`}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.primary.main}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
|
||||||
|
|
||||||
|
interface BarRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
fillRatio: number;
|
||||||
|
valueColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={theme.spacing(SPACING.LG)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={valueColor ?? theme.palette.primary.main}
|
||||||
|
fontWeight={500}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={fillRatio} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface CurrentlyDownCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.currentlyDown")}
|
||||||
|
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
|
||||||
|
>
|
||||||
|
{downMonitors.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.currentlyDown.empty")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{downMonitors.map((monitor) => (
|
||||||
|
<Stack
|
||||||
|
key={monitor.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Dot color={theme.palette.error.main} />
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
overflow="hidden"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{monitor.name}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { MonitorsSummary } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface MonitorStatusCardProps {
|
||||||
|
summary: MonitorsSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const up = summary?.upMonitors ?? 0;
|
||||||
|
const total = summary?.totalMonitors ?? 0;
|
||||||
|
const ratio = total > 0 ? up / total : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
|
||||||
|
{total === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(SPACING.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
gap={theme.spacing(SPACING.XS)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.xxl}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{up}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.monitorStatus.ofUp", { total })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={ratio} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import type { MonitorTypeCount } from "@/Types/Monitor";
|
||||||
|
import { getMonitorTypeLabel } from "@/Types/Monitor";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
interface MonitorsByTypeCardProps {
|
||||||
|
monitorsByType: MonitorTypeCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const maxCount = monitorsByType[0]?.count ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
|
||||||
|
{monitorsByType.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{monitorsByType.map(({ type, count }) => (
|
||||||
|
<BarRow
|
||||||
|
key={type}
|
||||||
|
label={getMonitorTypeLabel(type, t)}
|
||||||
|
value={String(count)}
|
||||||
|
fillRatio={count / maxCount}
|
||||||
|
valueColor={theme.palette.text.primary}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { AppDispatch, RootState } from "@/store";
|
||||||
|
|
||||||
|
const TOP_N = 5;
|
||||||
|
|
||||||
|
interface UptimeCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
|
||||||
|
|
||||||
|
const direction = sortOrder === "ascending" ? 1 : -1;
|
||||||
|
const ranked = monitors
|
||||||
|
.filter((monitor) => monitor.status !== "paused")
|
||||||
|
.filter(
|
||||||
|
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||||
|
typeof monitor.uptimePercentage === "number"
|
||||||
|
)
|
||||||
|
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
|
||||||
|
.slice(0, TOP_N);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.uptime")}
|
||||||
|
topRight={
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
|
||||||
|
onClick={() =>
|
||||||
|
dispatch(
|
||||||
|
setDashboardUptimeSortOrder(
|
||||||
|
sortOrder === "ascending" ? "descending" : "ascending"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
|
||||||
|
size={16}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ranked.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{ranked.map((monitor) => {
|
||||||
|
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
|
||||||
|
const color = theme.palette[paletteKey].main;
|
||||||
|
return (
|
||||||
|
<BarRow
|
||||||
|
key={monitor.id}
|
||||||
|
label={monitor.name}
|
||||||
|
value={formatPercentage(monitor.uptimePercentage)}
|
||||||
|
fillRatio={monitor.uptimePercentage}
|
||||||
|
valueColor={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,72 +1,99 @@
|
|||||||
|
import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { LayoutGrid } from "lucide-react";
|
|
||||||
|
|
||||||
import { BasePage, EmptyState, Icon } from "@/Components/design-elements";
|
import { BasePage } from "@/Components/design-elements/BasePage";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button } from "@/Components/inputs";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { CardGrid } from "./Components/CardGrid";
|
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
import { CardPicker } from "./Components/CardPicker";
|
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
|
||||||
import { MonitorsContext, useMonitorsRequest } from "./useDashboardData";
|
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
|
||||||
import { useCardSelection } from "./useCardSelection";
|
import type { RootState, AppDispatch } from "@/store";
|
||||||
|
|
||||||
|
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
|
||||||
|
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
|
||||||
|
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
|
||||||
|
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
||||||
|
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
||||||
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const monitorsRequest = useMonitorsRequest();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const { cards, available, selected, addCard, removeCard, moveCard, resetCards } =
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
useCardSelection();
|
const visibleCardsList = useSelector(
|
||||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
(state: RootState) => state.ui.dashboardVisibleCards
|
||||||
|
);
|
||||||
|
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
|
||||||
|
|
||||||
|
const { monitors, summary } = useMonitorListController({
|
||||||
|
types: [...MonitorTypes],
|
||||||
|
checksLimit: 1,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
rowsPerPageTable: "dashboard",
|
||||||
|
rowsPerPageDefault: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
|
||||||
|
const countMap = new Map<MonitorTypeCount["type"], number>();
|
||||||
|
(monitors ?? []).forEach((m) =>
|
||||||
|
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
|
||||||
|
);
|
||||||
|
return [...countMap.entries()]
|
||||||
|
.map(([type, count]) => ({ type, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}, [monitors]);
|
||||||
|
|
||||||
|
const cardComponents: Record<DashboardCardKey, ReactNode> = {
|
||||||
|
monitorStatus: <MonitorStatusCard summary={summary} />,
|
||||||
|
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
|
||||||
|
uptime: <UptimeCard monitors={monitors ?? []} />,
|
||||||
|
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MonitorsContext.Provider value={monitorsRequest}>
|
<BasePage headerKey="dashboard">
|
||||||
<BasePage headerKey="dashboard">
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
<Stack
|
||||||
<Stack
|
direction="row"
|
||||||
direction="row"
|
justifyContent="flex-end"
|
||||||
justifyContent="flex-end"
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
>
|
>
|
||||||
<Button
|
{t("pages.dashboard.editCards")}
|
||||||
variant="outlined"
|
</Button>
|
||||||
color="secondary"
|
|
||||||
onClick={() => setIsPickerOpen(true)}
|
|
||||||
startIcon={
|
|
||||||
<Icon
|
|
||||||
icon={LayoutGrid}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("pages.dashboard.picker.trigger")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
{cards.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t("pages.dashboard.empty.title")}
|
|
||||||
description={t("pages.dashboard.empty.description")}
|
|
||||||
actionText={t("pages.dashboard.picker.trigger")}
|
|
||||||
onAction={() => setIsPickerOpen(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CardGrid
|
|
||||||
cards={cards}
|
|
||||||
onMove={moveCard}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<CardPicker
|
|
||||||
open={isPickerOpen}
|
<Box
|
||||||
available={available}
|
display="grid"
|
||||||
selected={selected}
|
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
|
||||||
onAdd={addCard}
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
onRemove={removeCard}
|
>
|
||||||
onReset={resetCards}
|
{dashboardCardKeys
|
||||||
onClose={() => setIsPickerOpen(false)}
|
.filter((key) => visibleCards.has(key))
|
||||||
/>
|
.map((key) => (
|
||||||
</BasePage>
|
<Fragment key={key}>{cardComponents[key]}</Fragment>
|
||||||
</MonitorsContext.Provider>
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<EditCardsModal
|
||||||
|
open={editOpen}
|
||||||
|
visibleCards={visibleCards}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSave={(next) => {
|
||||||
|
dispatch(setDashboardVisibleCards([...next]));
|
||||||
|
setEditOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BasePage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { CardDefinition } from "./cards";
|
|
||||||
|
|
||||||
export interface PlacedCard {
|
|
||||||
card: CardDefinition;
|
|
||||||
/** Columns rendered — currently always the declared width. */
|
|
||||||
renderedWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Packs cards into rows of GRID_COLUMNS.
|
|
||||||
*
|
|
||||||
* With a single body width this is a straight pass-through: two halves fill a
|
|
||||||
* row exactly, so no card is ever stretched to close a gap.
|
|
||||||
*
|
|
||||||
* An odd number of cards leaves the last one alone on its row. It keeps its
|
|
||||||
* declared width rather than being promoted to full: widening one card to fill
|
|
||||||
* the row makes it look more important than it is, and an odd count is a
|
|
||||||
* property of the selection, not something the layout should hide.
|
|
||||||
*/
|
|
||||||
export const layoutCards = (cards: CardDefinition[]): PlacedCard[] =>
|
|
||||||
cards.map((card) => ({ card, renderedWidth: card.width }));
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { MonitorStatusCard } from "./Components/Cards/MonitorStatusCard";
|
|
||||||
import { CurrentlyDownCard } from "./Components/Cards/CurrentlyDownCard";
|
|
||||||
import { FailedChecksCard } from "./Components/Cards/FailedChecksCard";
|
|
||||||
import { RecentIncidentsCard } from "./Components/Cards/RecentIncidentsCard";
|
|
||||||
import { MonitorsByTypeCard } from "./Components/Cards/MonitorsByTypeCard";
|
|
||||||
import { MonitorsByGroupCard } from "./Components/Cards/MonitorsByGroupCard";
|
|
||||||
import { CheckmateServerCard } from "./Components/Cards/CheckmateServerCard";
|
|
||||||
import { BusiestServersCard } from "./Components/Cards/BusiestServersCard";
|
|
||||||
import { SlowestMonitorsCard, LowestUptimeCard } from "./Components/Cards/RankingCards";
|
|
||||||
import {
|
|
||||||
IncidentStatsCard,
|
|
||||||
NotificationChannelsCard,
|
|
||||||
MaintenanceCard,
|
|
||||||
StatusPagesCard,
|
|
||||||
} from "./Components/Cards/SummaryCards";
|
|
||||||
import {
|
|
||||||
ChecksOnScheduleCard,
|
|
||||||
RecentErrorsCard,
|
|
||||||
TeamCard,
|
|
||||||
} from "./Components/Cards/AdminCards";
|
|
||||||
|
|
||||||
import type { CardDefinition, CardId } from "./cards";
|
|
||||||
|
|
||||||
// Declared width is the minimum number of columns a card needs. The grid may
|
|
||||||
// stretch the last card of a short row; nothing here is a user setting.
|
|
||||||
//
|
|
||||||
// Order is the render order, and the order the picker lists them in.
|
|
||||||
//
|
|
||||||
// Every card is half-width and pairs up down the page. The grid promotes a
|
|
||||||
// lone trailing card to full width so the page does not end on a ragged half.
|
|
||||||
export const CARD_REGISTRY: CardDefinition[] = [
|
|
||||||
{
|
|
||||||
id: "monitorStatus",
|
|
||||||
key: "monitorStatus",
|
|
||||||
group: "uptime",
|
|
||||||
width: 6,
|
|
||||||
component: MonitorStatusCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "currentlyDown",
|
|
||||||
key: "currentlyDown",
|
|
||||||
group: "uptime",
|
|
||||||
width: 6,
|
|
||||||
component: CurrentlyDownCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "failedChecks",
|
|
||||||
key: "failedChecks",
|
|
||||||
group: "checks",
|
|
||||||
width: 6,
|
|
||||||
component: FailedChecksCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "slowestMonitors",
|
|
||||||
key: "slowestMonitors",
|
|
||||||
group: "uptime",
|
|
||||||
width: 6,
|
|
||||||
component: SlowestMonitorsCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lowestUptime",
|
|
||||||
key: "lowestUptime",
|
|
||||||
group: "uptime",
|
|
||||||
width: 6,
|
|
||||||
component: LowestUptimeCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "monitorsByType",
|
|
||||||
key: "monitorsByType",
|
|
||||||
group: "uptime",
|
|
||||||
width: 6,
|
|
||||||
component: MonitorsByTypeCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "monitorsByGroup",
|
|
||||||
key: "monitorsByGroup",
|
|
||||||
group: "uptime",
|
|
||||||
width: 6,
|
|
||||||
component: MonitorsByGroupCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "busiestServers",
|
|
||||||
key: "busiestServers",
|
|
||||||
group: "infrastructure",
|
|
||||||
width: 6,
|
|
||||||
component: BusiestServersCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "recentIncidents",
|
|
||||||
key: "recentIncidents",
|
|
||||||
group: "incidents",
|
|
||||||
width: 6,
|
|
||||||
component: RecentIncidentsCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "incidentStats",
|
|
||||||
key: "incidentStats",
|
|
||||||
group: "incidents",
|
|
||||||
width: 6,
|
|
||||||
component: IncidentStatsCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notificationChannels",
|
|
||||||
key: "notificationChannels",
|
|
||||||
group: "notifications",
|
|
||||||
width: 6,
|
|
||||||
component: NotificationChannelsCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maintenance",
|
|
||||||
key: "maintenance",
|
|
||||||
group: "maintenance",
|
|
||||||
width: 6,
|
|
||||||
component: MaintenanceCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "statusPages",
|
|
||||||
key: "statusPages",
|
|
||||||
group: "statusPages",
|
|
||||||
width: 6,
|
|
||||||
component: StatusPagesCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "checkmateServer",
|
|
||||||
key: "checkmateServer",
|
|
||||||
group: "logs",
|
|
||||||
width: 6,
|
|
||||||
adminOnly: true,
|
|
||||||
component: CheckmateServerCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "checksOnSchedule",
|
|
||||||
key: "checksOnSchedule",
|
|
||||||
group: "logs",
|
|
||||||
width: 6,
|
|
||||||
adminOnly: true,
|
|
||||||
component: ChecksOnScheduleCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "recentErrors",
|
|
||||||
key: "recentErrors",
|
|
||||||
group: "logs",
|
|
||||||
width: 6,
|
|
||||||
adminOnly: true,
|
|
||||||
component: RecentErrorsCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "team",
|
|
||||||
key: "team",
|
|
||||||
group: "settings",
|
|
||||||
width: 6,
|
|
||||||
adminOnly: true,
|
|
||||||
component: TeamCard,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BY_ID = new Map<CardId, CardDefinition>(
|
|
||||||
CARD_REGISTRY.map((card) => [card.id, card])
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getCard = (id: CardId): CardDefinition | undefined => BY_ID.get(id);
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { logger } from "@/Utils/logger";
|
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
|
||||||
import {
|
|
||||||
CardIds,
|
|
||||||
DASHBOARD_CARDS_STORAGE_KEY,
|
|
||||||
DEFAULT_CARD_IDS,
|
|
||||||
type CardId,
|
|
||||||
} from "./cards";
|
|
||||||
import { CARD_REGISTRY, getCard } from "./registry";
|
|
||||||
|
|
||||||
import type { CardDefinition } from "./cards";
|
|
||||||
|
|
||||||
const isCardId = (value: unknown): value is CardId =>
|
|
||||||
typeof value === "string" && (CardIds as readonly string[]).includes(value);
|
|
||||||
|
|
||||||
/** Stored ids are validated: a card removed from a release must not break the page. */
|
|
||||||
const readStored = (): CardId[] | null => {
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(DASHBOARD_CARDS_STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// An empty array is a real choice and must not fall back to the defaults;
|
|
||||||
// only an absent or malformed key returns null. Unknown ids are dropped
|
|
||||||
// so a card removed in a later release cannot break the page.
|
|
||||||
return parsed.filter(isCardId);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
"Could not read dashboard card selection",
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which cards are on the page, persisted per browser. Admin-only cards are
|
|
||||||
* filtered out entirely for non-admins — hidden, not disabled.
|
|
||||||
*/
|
|
||||||
export const useCardSelection = () => {
|
|
||||||
const isAdmin = useIsAdmin();
|
|
||||||
const [selected, setSelected] = useState<CardId[]>(
|
|
||||||
() => readStored() ?? [...DEFAULT_CARD_IDS]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip the write on mount: if the read failed and fell back to the defaults,
|
|
||||||
// writing would overwrite the user's real selection permanently.
|
|
||||||
const hasMounted = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasMounted.current) {
|
|
||||||
hasMounted.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(DASHBOARD_CARDS_STORAGE_KEY, JSON.stringify(selected));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
"Could not persist dashboard card selection",
|
|
||||||
error instanceof Error ? error : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const isVisible = useCallback(
|
|
||||||
(card: CardDefinition) => !card.adminOnly || isAdmin,
|
|
||||||
[isAdmin]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Selection order is the render order.
|
|
||||||
const cards = useMemo(
|
|
||||||
() =>
|
|
||||||
selected
|
|
||||||
.map(getCard)
|
|
||||||
.filter((card): card is CardDefinition => card !== undefined)
|
|
||||||
.filter(isVisible),
|
|
||||||
[selected, isVisible]
|
|
||||||
);
|
|
||||||
|
|
||||||
const available = useMemo(() => CARD_REGISTRY.filter(isVisible), [isVisible]);
|
|
||||||
|
|
||||||
const addCard = useCallback((id: CardId) => {
|
|
||||||
setSelected((current) => (current.includes(id) ? current : [...current, id]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeCard = useCallback((id: CardId) => {
|
|
||||||
setSelected((current) => current.filter((cardId) => cardId !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetCards = useCallback(() => setSelected([...DEFAULT_CARD_IDS]), []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swaps a card with its neighbour; `delta` is -1 for up, +1 for down.
|
|
||||||
* Resolved against the visible order, so moving past a card hidden from a
|
|
||||||
* non-admin does not appear to do nothing.
|
|
||||||
*/
|
|
||||||
const moveCard = useCallback(
|
|
||||||
(id: CardId, delta: -1 | 1) => {
|
|
||||||
setSelected((current) => {
|
|
||||||
const visible = current.filter((cardId) => {
|
|
||||||
const card = getCard(cardId);
|
|
||||||
return card !== undefined && isVisible(card);
|
|
||||||
});
|
|
||||||
const from = visible.indexOf(id);
|
|
||||||
const to = from + delta;
|
|
||||||
if (from === -1 || to < 0 || to >= visible.length) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
// Swap in the visible order, then write that order back over the
|
|
||||||
// stored positions the visible cards occupy, leaving hidden cards
|
|
||||||
// exactly where they were.
|
|
||||||
const reordered = [...visible];
|
|
||||||
[reordered[from], reordered[to]] = [reordered[to], reordered[from]];
|
|
||||||
|
|
||||||
let next = 0;
|
|
||||||
return current.map((cardId) =>
|
|
||||||
visible.includes(cardId) ? reordered[next++] : cardId
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[isVisible]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
cards,
|
|
||||||
available,
|
|
||||||
selected,
|
|
||||||
addCard,
|
|
||||||
removeCard,
|
|
||||||
moveCard,
|
|
||||||
resetCards,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import type { CardId } from "./cards";
|
|
||||||
|
|
||||||
/** Long enough to follow a card across a row, short enough not to feel slow. */
|
|
||||||
const DURATION_MS = 260;
|
|
||||||
/** Decelerating: quick to leave, gentle to settle. */
|
|
||||||
const EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
|
||||||
|
|
||||||
export const useCardTransitions = () => {
|
|
||||||
const nodes = useRef(new Map<CardId, HTMLElement>());
|
|
||||||
const positions = useRef(new Map<CardId, DOMRect>());
|
|
||||||
/** Set only for the render that follows a reorder, so nothing animates on data refresh. */
|
|
||||||
const isAnimating = useRef(false);
|
|
||||||
/** The card the user moved, lifted above the rest while it travels. */
|
|
||||||
const movedCard = useRef<CardId | null>(null);
|
|
||||||
/** Pending frame and settle handles, cancelled on unmount and on a new move. */
|
|
||||||
const frames = useRef<number[]>([]);
|
|
||||||
const timers = useRef<number[]>([]);
|
|
||||||
|
|
||||||
const cancelPending = useCallback(() => {
|
|
||||||
frames.current.forEach(cancelAnimationFrame);
|
|
||||||
timers.current.forEach(clearTimeout);
|
|
||||||
frames.current = [];
|
|
||||||
timers.current = [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => cancelPending, [cancelPending]);
|
|
||||||
|
|
||||||
const registerCard = useCallback((id: CardId, node: HTMLElement | null) => {
|
|
||||||
if (node) {
|
|
||||||
nodes.current.set(id, node);
|
|
||||||
} else {
|
|
||||||
nodes.current.delete(id);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call immediately before the state change that reorders the cards.
|
|
||||||
* `movedId` is the card the user acted on; it rides above the others so the
|
|
||||||
* swap reads as one card moving rather than two sliding through each other.
|
|
||||||
*/
|
|
||||||
const capturePositions = useCallback(
|
|
||||||
(movedId: CardId) => {
|
|
||||||
// A move while a previous one is still settling would otherwise have
|
|
||||||
// its inversion wiped by the earlier timeout, and the card would jump.
|
|
||||||
cancelPending();
|
|
||||||
positions.current.clear();
|
|
||||||
for (const [id, node] of nodes.current) {
|
|
||||||
positions.current.set(id, node.getBoundingClientRect());
|
|
||||||
}
|
|
||||||
movedCard.current = movedId;
|
|
||||||
isAnimating.current = true;
|
|
||||||
},
|
|
||||||
[cancelPending]
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isAnimating.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isAnimating.current = false;
|
|
||||||
|
|
||||||
const reducedMotion = window.matchMedia?.(
|
|
||||||
"(prefers-reduced-motion: reduce)"
|
|
||||||
)?.matches;
|
|
||||||
if (reducedMotion) {
|
|
||||||
positions.current.clear();
|
|
||||||
movedCard.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, node] of nodes.current) {
|
|
||||||
const previous = positions.current.get(id);
|
|
||||||
if (!previous) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const current = node.getBoundingClientRect();
|
|
||||||
const deltaX = previous.left - current.left;
|
|
||||||
const deltaY = previous.top - current.top;
|
|
||||||
if (deltaX === 0 && deltaY === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invert: jump back to the old position with no transition...
|
|
||||||
node.style.transition = "none";
|
|
||||||
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
|
||||||
if (id === movedCard.current) {
|
|
||||||
node.style.zIndex = "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...then play: on the next frame, release to the new one.
|
|
||||||
frames.current.push(
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
node.style.transition = `transform ${DURATION_MS}ms ${EASING}`;
|
|
||||||
node.style.transform = "";
|
|
||||||
// Clear the inline styles once settled. Left in place they would
|
|
||||||
// animate the card again on any later reflow it did not ask for.
|
|
||||||
timers.current.push(
|
|
||||||
window.setTimeout(() => {
|
|
||||||
node.style.transition = "";
|
|
||||||
node.style.zIndex = "";
|
|
||||||
}, DURATION_MS)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
positions.current.clear();
|
|
||||||
movedCard.current = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { registerCard, capturePositions };
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
|
||||||
import { MonitorTypes, type MonitorsWithChecksResponse } from "@/Types/Monitor";
|
|
||||||
import { REFRESH_INTERVAL_MS } from "./cards";
|
|
||||||
|
|
||||||
// One request feeds every monitor-derived card, read from context so adding a
|
|
||||||
// card costs no extra call. No rowsPerPage: the dashboard summarises the whole
|
|
||||||
// fleet, and the repository treats its absence as "no limit".
|
|
||||||
const buildMonitorsUrl = () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
MonitorTypes.forEach((type) => params.append("type", type));
|
|
||||||
return `/monitors/team/with-checks?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMonitorsRequest = () =>
|
|
||||||
useGet<MonitorsWithChecksResponse>(
|
|
||||||
buildMonitorsUrl(),
|
|
||||||
{},
|
|
||||||
{ refreshInterval: REFRESH_INTERVAL_MS, keepPreviousData: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
export type MonitorsRequest = ReturnType<typeof useMonitorsRequest>;
|
|
||||||
|
|
||||||
export const MonitorsContext = createContext<MonitorsRequest | null>(null);
|
|
||||||
|
|
||||||
export const useMonitors = (): MonitorsRequest => {
|
|
||||||
const context = useContext(MonitorsContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useMonitors must be used inside the dashboard's MonitorsContext");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
Whitespace-only changes.
@@ -10,6 +10,8 @@ import type { DockerContainerInfo } from "@/Types/Check";
|
|||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
import { getDockerPalette } from "@/Utils/MonitorUtils";
|
||||||
|
|
||||||
export const DockerContainerStatusBoxes = ({
|
export const DockerContainerStatusBoxes = ({
|
||||||
container,
|
container,
|
||||||
@@ -22,8 +24,10 @@ export const DockerContainerStatusBoxes = ({
|
|||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
|
|
||||||
const { state, restartCount, health } = container;
|
const { state, restartCount, health } = container;
|
||||||
const startedAt = container.startedAt ?? "0";
|
const runningFor = container.startedAt
|
||||||
const runningFor = Date.now() - new Date(startedAt).getTime();
|
? Date.now() - new Date(container.startedAt).getTime()
|
||||||
|
: undefined;
|
||||||
|
const palette = getDockerPalette(state);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
@@ -33,14 +37,15 @@ export const DockerContainerStatusBoxes = ({
|
|||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.state")}
|
title={t("common.labels.state")}
|
||||||
subtitle={state}
|
subtitle={state}
|
||||||
|
palette={palette}
|
||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.uptime")}
|
title={t("common.labels.uptime")}
|
||||||
subtitle={formatDuration(runningFor)}
|
subtitle={runningFor === undefined ? PLACEHOLDER : formatDuration(runningFor)}
|
||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.restarts")}
|
title={t("common.labels.restarts")}
|
||||||
subtitle={String(restartCount ?? 0)}
|
subtitle={restartCount === undefined ? PLACEHOLDER : String(restartCount)}
|
||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.health")}
|
title={t("common.labels.health")}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useToast } from "@/Hooks/UseToast";
|
import { useToast } from "@/Hooks/UseToast";
|
||||||
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { get } from "@/Utils/ApiClient";
|
import { get } from "@/Utils/ApiClient";
|
||||||
@@ -50,6 +51,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toastError } = useToast();
|
const { toastError } = useToast();
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// logs is newest-first, matching the API and what flattenDockerLogs expects.
|
// logs is newest-first, matching the API and what flattenDockerLogs expects.
|
||||||
const [logs, setLogs] = useState<DockerLog[]>([]);
|
const [logs, setLogs] = useState<DockerLog[]>([]);
|
||||||
@@ -59,7 +61,7 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
|
|||||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
|
||||||
|
|
||||||
const logsUrl =
|
const logsUrl =
|
||||||
enabled && monitorId && containerName
|
isAdmin && enabled && monitorId && containerName
|
||||||
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
|
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -168,6 +170,15 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) =>
|
|||||||
}
|
}
|
||||||
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
|
}, [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) {
|
if (!enabled) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ import type { Theme } from "@mui/material";
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { BaseBox } from "@/Components/design-elements";
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
|
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
|
||||||
{
|
{
|
||||||
@@ -24,20 +25,29 @@ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats
|
|||||||
key: "avgCpuPct",
|
key: "avgCpuPct",
|
||||||
color: theme.palette.success.main,
|
color: theme.palette.success.main,
|
||||||
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
|
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
|
||||||
|
formatter: formatPercentage,
|
||||||
|
labelKey: "common.charts.histogram.avgCpuUsage",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("common.charts.labels.memoryUsage"),
|
title: t("common.charts.labels.memoryUsage"),
|
||||||
key: "avgMemoryUsedBytes",
|
key: "avgMemoryUsedBytes",
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
||||||
|
formatter: prettyBytes,
|
||||||
|
labelKey: "common.charts.histogram.avgMemoryUsage",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
export const TabOverview = ({
|
||||||
|
stats,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
stats: DockerContainerStats;
|
||||||
|
range: DateRange;
|
||||||
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
const { ports, mounts } = stats?.latest?.container ?? {};
|
const { ports, mounts } = stats?.latest?.container ?? {};
|
||||||
const dedupedPorts = dedupeDockerPorts(ports ?? []);
|
const dedupedPorts = dedupeDockerPorts(ports ?? []);
|
||||||
return (
|
return (
|
||||||
@@ -58,6 +68,9 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
|||||||
dataKey={config.key}
|
dataKey={config.key}
|
||||||
strokeColor={config.color}
|
strokeColor={config.color}
|
||||||
gradientStartColor={config.color}
|
gradientStartColor={config.color}
|
||||||
|
range={range}
|
||||||
|
formatter={config.formatter}
|
||||||
|
labelKey={config.labelKey}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
@@ -75,11 +88,17 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
|||||||
Ports
|
Ports
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack>
|
<Stack>
|
||||||
{dedupedPorts?.map((port) => {
|
{dedupedPorts.length === 0 && <Typography>{PLACEHOLDER}</Typography>}
|
||||||
|
{dedupedPorts.map((port) => {
|
||||||
|
const containerPort = `${port.privatePort}/${port.protocol}`;
|
||||||
|
const label =
|
||||||
|
port.publicPort === undefined
|
||||||
|
? containerPort
|
||||||
|
: `${port.hostIp ?? "0.0.0.0"}:${port.publicPort} -> ${containerPort}`;
|
||||||
return (
|
return (
|
||||||
<Typography
|
<Typography key={`${port.hostIp}/${port.publicPort}/${containerPort}`}>
|
||||||
key={`${port.publicPort}/${port.privatePort}`}
|
{label}
|
||||||
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -98,6 +117,7 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
|||||||
Volumes
|
Volumes
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
|
||||||
{mounts?.map((mount) => {
|
{mounts?.map((mount) => {
|
||||||
return (
|
return (
|
||||||
<Typography key={`${mount.source}/${mount.destination}`}>
|
<Typography key={`${mount.source}/${mount.destination}`}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const TAB_MAP = {
|
|||||||
logs: 1,
|
logs: 1,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
|
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
|
||||||
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
|
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
const [dateRange, setDateRange] = useState<DateRange>("recent");
|
||||||
const { monitorId, containerName } = useParams<{
|
const { monitorId, containerName } = useParams<{
|
||||||
@@ -69,7 +69,12 @@ const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
|
|||||||
<Tab label={t("pages.docker.container.tabs.overview")} />
|
<Tab label={t("pages.docker.container.tabs.overview")} />
|
||||||
<Tab label={t("pages.docker.container.tabs.logs")} />
|
<Tab label={t("pages.docker.container.tabs.logs")} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{activeTab === 0 && <TabOverview stats={stats} />}
|
{activeTab === 0 && (
|
||||||
|
<TabOverview
|
||||||
|
stats={stats}
|
||||||
|
range={dateRange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeTab === 1 && (
|
{activeTab === 1 && (
|
||||||
<TabLogs
|
<TabLogs
|
||||||
monitorId={monitorId}
|
monitorId={monitorId}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Header } from "@/Components/design-elements/Table";
|
|||||||
import type { DockerContainerInfo } from "@/Types/Check";
|
import type { DockerContainerInfo } from "@/Types/Check";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface DockerContainersTableProps {
|
interface DockerContainersTableProps {
|
||||||
monitorId: string | undefined;
|
monitorId: string | undefined;
|
||||||
@@ -91,7 +91,7 @@ export const DockerContainersTable = ({
|
|||||||
id: "restarts",
|
id: "restarts",
|
||||||
content: t("common.labels.restarts"),
|
content: t("common.labels.restarts"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
return <Typography>{row.restartCount}</Typography>;
|
return <Typography>{row.restartCount ?? PLACEHOLDER}</Typography>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { DockerContainerSummary } from "@/Types/Check";
|
import type { DockerContainerSummary } from "@/Types/Check";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const DockerMonitorsPage = () => {
|
|||||||
controller={c}
|
controller={c}
|
||||||
bulkActions
|
bulkActions
|
||||||
showTypeFilter={false}
|
showTypeFilter={false}
|
||||||
|
summaryProps={{ showBreached: true }}
|
||||||
>
|
>
|
||||||
<DockerMonitorsTable
|
<DockerMonitorsTable
|
||||||
monitors={c.monitors || []}
|
monitors={c.monitors || []}
|
||||||
@@ -30,9 +31,8 @@ const DockerMonitorsPage = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
selectedRows={c.selectedRows}
|
selectedRows={c.selectedRows}
|
||||||
onSelectionChange={c.setSelectedRows}
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
interface InfraMonitorsTableProps {
|
interface InfraMonitorsTableProps {
|
||||||
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
selectedRows={c.selectedRows}
|
selectedRows={c.selectedRows}
|
||||||
onSelectionChange={c.setSelectedRows}
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { DetailGauge } from "@/Components/design-elements";
|
import { DetailGauge } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
|
import { getPercentage, formatPercentageFromWhole, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
@@ -11,7 +11,6 @@ interface StatGaugesProps {
|
|||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "—";
|
|
||||||
|
|
||||||
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
|
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { useTheme } from "@mui/material";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||||
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
||||||
|
import { PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "—";
|
|
||||||
|
|
||||||
export const Stats = ({ diagnostics }: StatsProps) => {
|
export const Stats = ({ diagnostics }: StatsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
const LogsPage = () => {
|
const LogsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<number>(1);
|
const [activeTab, setActiveTab] = useState<number>(0);
|
||||||
return (
|
return (
|
||||||
<BasePage headerKey="logs">
|
<BasePage headerKey="logs">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
|
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
|
||||||
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,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component={NotFoundSvg}
|
||||||
src={NotFoundSvg}
|
role="img"
|
||||||
alt="404"
|
aria-label="404"
|
||||||
maxHeight={"25rem"}
|
width="100%"
|
||||||
|
maxHeight="25rem"
|
||||||
/>
|
/>
|
||||||
<Typography
|
<Typography
|
||||||
component="h1"
|
component="h1"
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ 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
|
||||||
@@ -201,6 +202,31 @@ const NotificationsCreatePage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{watchedType === "signalgrid" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.notifications.form.signalgrid.title")}
|
||||||
|
subtitle={t("pages.notifications.form.signalgrid.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack spacing={theme.spacing(8)}>
|
||||||
|
<FormTextField
|
||||||
|
name="accessToken"
|
||||||
|
fieldLabel={t("pages.notifications.form.signalgrid.optionClientKey")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.signalgrid.placeholderClientKey"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormTextField
|
||||||
|
name="address"
|
||||||
|
fieldLabel={t("pages.notifications.form.signalgrid.optionChannel")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.notifications.form.signalgrid.placeholderChannel"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{watchedType === "twilio" && (
|
{watchedType === "twilio" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.notifications.form.twilio.title")}
|
title={t("pages.notifications.form.twilio.title")}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
|
|
||||||
export const PageSpeedMonitorsTable = ({
|
export const PageSpeedMonitorsTable = ({
|
||||||
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch: Function;
|
refetch: Function;
|
||||||
setSelectedMonitor: Function;
|
setSelectedMonitor: Function;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
const handleSort = (e: any, field: string) => {
|
const handleSort = (e: any, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
/>
|
/>
|
||||||
</MonitorListPage>
|
</MonitorListPage>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||||
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||||
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||||
|
import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal";
|
||||||
import {
|
import {
|
||||||
ThemedInfrastructure,
|
ThemedInfrastructure,
|
||||||
type GaugeFillLevel,
|
type GaugeFillLevel,
|
||||||
@@ -116,6 +117,8 @@ export const BaseStatusPage = ({
|
|||||||
[config, tokens, mode]
|
[config, tokens, mode]
|
||||||
);
|
);
|
||||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||||
|
const [selectedMonitorId, setSelectedMonitorId] = useState<string | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
||||||
const logoSrc = statusPage.logo?.data
|
const logoSrc = statusPage.logo?.data
|
||||||
@@ -306,6 +309,10 @@ export const BaseStatusPage = ({
|
|||||||
cells={cells}
|
cells={cells}
|
||||||
containerSx={styles.heatmap}
|
containerSx={styles.heatmap}
|
||||||
cellSx={styles.heatmapCell}
|
cellSx={styles.heatmapCell}
|
||||||
|
onCellClick={(date) => {
|
||||||
|
setSelectedMonitorId(monitor.id);
|
||||||
|
setSelectedDate(date);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThemedHistogram
|
<ThemedHistogram
|
||||||
@@ -313,6 +320,10 @@ export const BaseStatusPage = ({
|
|||||||
containerSx={styles.histogram}
|
containerSx={styles.histogram}
|
||||||
barSx={styles.bar}
|
barSx={styles.bar}
|
||||||
statsSx={styles.chartStats}
|
statsSx={styles.chartStats}
|
||||||
|
onCellClick={(date) => {
|
||||||
|
setSelectedMonitorId(monitor.id);
|
||||||
|
setSelectedDate(date);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -333,6 +344,16 @@ export const BaseStatusPage = ({
|
|||||||
Checkmate
|
Checkmate
|
||||||
</a>
|
</a>
|
||||||
</Box>
|
</Box>
|
||||||
|
<IncidentModal
|
||||||
|
url={statusPage.url}
|
||||||
|
monitorId={selectedMonitorId}
|
||||||
|
date={selectedDate}
|
||||||
|
timezone={bucketTimezone}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedMonitorId(null);
|
||||||
|
setSelectedDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -25,6 +25,7 @@ export interface ChartCell {
|
|||||||
responseTime: number; // 0 for empty cells
|
responseTime: number; // 0 for empty cells
|
||||||
tooltip: React.ReactNode | null;
|
tooltip: React.ReactNode | null;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
|
date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_DOWN_THRESHOLD = 0.5;
|
const DAY_DOWN_THRESHOLD = 0.5;
|
||||||
@@ -61,7 +62,7 @@ export const classifyResponseTime = (
|
|||||||
return "fast";
|
return "fast";
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyCell = (key: string): ChartCell => ({
|
const emptyCell = (key: string, date: string | null = null): ChartCell => ({
|
||||||
key,
|
key,
|
||||||
barKind: "empty",
|
barKind: "empty",
|
||||||
heatKind: "empty",
|
heatKind: "empty",
|
||||||
@@ -70,6 +71,7 @@ const emptyCell = (key: string): ChartCell => ({
|
|||||||
tooltip: null,
|
tooltip: null,
|
||||||
ariaLabel: "",
|
ariaLabel: "",
|
||||||
severity: 0,
|
severity: 0,
|
||||||
|
date,
|
||||||
});
|
});
|
||||||
|
|
||||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||||
@@ -92,6 +94,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
|||||||
tooltip: <ThemedChartTooltip check={check} />,
|
tooltip: <ThemedChartTooltip check={check} />,
|
||||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||||
severity: 0,
|
severity: 0,
|
||||||
|
date: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return padCells(cells, MAX_RECENT_CHECKS);
|
return padCells(cells, MAX_RECENT_CHECKS);
|
||||||
@@ -108,7 +111,7 @@ export const dailyBucketsToCells = (
|
|||||||
const enumerated = enumerateDays(days, bucketTimezone);
|
const enumerated = enumerateDays(days, bucketTimezone);
|
||||||
return enumerated.map((date) => {
|
return enumerated.map((date) => {
|
||||||
const bucket = byDate.get(date);
|
const bucket = byDate.get(date);
|
||||||
if (!bucket) return emptyCell(`day-${date}`);
|
if (!bucket) return emptyCell(`day-${date}`, date);
|
||||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||||
|
|
||||||
const barKind = classifyDay(bucket, upRatio);
|
const barKind = classifyDay(bucket, upRatio);
|
||||||
@@ -132,6 +135,7 @@ export const dailyBucketsToCells = (
|
|||||||
date,
|
date,
|
||||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
||||||
}),
|
}),
|
||||||
|
date,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import { X as XIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
|
import type { Incident } from "@/Types/Incident";
|
||||||
|
interface IncidentModalProps {
|
||||||
|
url: string;
|
||||||
|
monitorId: string | null;
|
||||||
|
date: string | null;
|
||||||
|
timezone: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentModal = ({
|
||||||
|
url,
|
||||||
|
monitorId,
|
||||||
|
date,
|
||||||
|
timezone,
|
||||||
|
onClose,
|
||||||
|
}: IncidentModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = Boolean(monitorId && date);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useGet<{ incidents: Incident[] }>(
|
||||||
|
open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null,
|
||||||
|
{},
|
||||||
|
{ revalidateOnFocus: false, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.modalTitle", { date })} ({timezone})
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
size="small"
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography mt={SPACING.SM}>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.loading")}
|
||||||
|
</Typography>
|
||||||
|
) : error ? (
|
||||||
|
<Typography
|
||||||
|
mt={SPACING.SM}
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.errorLoading")}
|
||||||
|
</Typography>
|
||||||
|
) : data?.incidents && data.incidents.length > 0 ? (
|
||||||
|
<Stack
|
||||||
|
gap={SPACING.LG}
|
||||||
|
mt={SPACING.SM}
|
||||||
|
>
|
||||||
|
{data.incidents.map((incident) => (
|
||||||
|
<Stack
|
||||||
|
key={incident.id}
|
||||||
|
p={SPACING.LG}
|
||||||
|
border={1}
|
||||||
|
borderColor="divider"
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{incident.message ||
|
||||||
|
(incident.statusCode ? `Error ${incident.statusCode}` : "Incident")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(incident.startTime, "h:mm A", timezone)} -{" "}
|
||||||
|
{incident.endTime
|
||||||
|
? formatDateWithTz(incident.endTime, "h:mm A", timezone)
|
||||||
|
: t("pages.statusPages.monitorsList.incidents.ongoing")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography mt={SPACING.SM}>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.noIncidents")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ import Tooltip from "@mui/material/Tooltip";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChartCell,
|
ChartCell,
|
||||||
HeatCellKind,
|
HeatCellKind,
|
||||||
@@ -11,17 +13,15 @@ interface Props {
|
|||||||
cells: ChartCell[];
|
cells: ChartCell[];
|
||||||
containerSx: SxProps<Theme>;
|
containerSx: SxProps<Theme>;
|
||||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||||
|
onCellClick?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||||
>
|
>
|
||||||
@@ -30,7 +30,15 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={cell.key}
|
||||||
sx={cellSx("empty")}
|
sx={mergeSx(
|
||||||
|
cellSx("empty"),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,8 +50,16 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={cellSx(cell.heatKind, cell.severity)}
|
sx={mergeSx(
|
||||||
|
cellSx(cell.heatKind, cell.severity),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={cell.ariaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Stack from "@mui/material/Stack";
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
import { formatMs } from "@/Utils/TimeUtils";
|
||||||
|
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BarKind,
|
BarKind,
|
||||||
ChartCell,
|
ChartCell,
|
||||||
@@ -16,6 +18,7 @@ interface Props {
|
|||||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||||
statsSx: SxProps<Theme>;
|
statsSx: SxProps<Theme>;
|
||||||
statsGap?: number;
|
statsGap?: number;
|
||||||
|
onCellClick?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHistogram = ({
|
export const ThemedHistogram = ({
|
||||||
@@ -24,6 +27,7 @@ export const ThemedHistogram = ({
|
|||||||
barSx,
|
barSx,
|
||||||
statsSx,
|
statsSx,
|
||||||
statsGap = 1,
|
statsGap = 1,
|
||||||
|
onCellClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -39,17 +43,22 @@ export const ThemedHistogram = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap={statsGap}>
|
<Stack gap={statsGap}>
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{cells.map((cell) => {
|
{cells.map((cell) => {
|
||||||
if (cell.barKind === "empty") {
|
if (cell.barKind === "empty") {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={cell.key}
|
||||||
sx={barSx("empty", cell.heightPct)}
|
sx={mergeSx(
|
||||||
|
barSx("empty", cell.heightPct),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,8 +70,16 @@ export const ThemedHistogram = ({
|
|||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
sx={mergeSx(
|
||||||
|
barSx(cell.barKind, cell.heightPct, cell.severity),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={cell.ariaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export const mergeSx = (
|
||||||
|
...styles: Array<SxProps<Theme> | false | null | undefined>
|
||||||
|
): SxProps<Theme> => {
|
||||||
|
const result: SxProps<Theme>[] = [];
|
||||||
|
for (const style of styles) {
|
||||||
|
if (!style) continue;
|
||||||
|
if (Array.isArray(style)) {
|
||||||
|
result.push(...(style.filter(Boolean) as SxProps<Theme>[]));
|
||||||
|
} else {
|
||||||
|
result.push(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as SxProps<Theme>;
|
||||||
|
};
|
||||||
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface MonitorTableProps {
|
interface MonitorTableProps {
|
||||||
@@ -34,9 +35,8 @@ interface MonitorTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -52,9 +52,8 @@ export const MonitorTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -91,14 +90,8 @@ export const MonitorTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
|
|||||||
setPage={monitorListController.setPage}
|
setPage={monitorListController.setPage}
|
||||||
rowsPerPage={monitorListController.rowsPerPage}
|
rowsPerPage={monitorListController.rowsPerPage}
|
||||||
sortField={monitorListController.sortField}
|
sortField={monitorListController.sortField}
|
||||||
setSortField={monitorListController.setSortField}
|
|
||||||
sortOrder={monitorListController.sortOrder}
|
sortOrder={monitorListController.sortOrder}
|
||||||
setSortOrder={monitorListController.setSortOrder}
|
setSort={monitorListController.setSort}
|
||||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||||
selectedRows={monitorListController.selectedRows}
|
selectedRows={monitorListController.selectedRows}
|
||||||
onSelectionChange={monitorListController.setSelectedRows}
|
onSelectionChange={monitorListController.setSelectedRows}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const Routes = () => {
|
|||||||
element={<Navigate to="/uptime" />}
|
element={<Navigate to="/uptime" />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="dashboard"
|
||||||
element={<Dashboard />}
|
element={<Dashboard />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const dashboardCardKeys = [
|
||||||
|
"monitorStatus",
|
||||||
|
"currentlyDown",
|
||||||
|
"uptime",
|
||||||
|
"monitorsByType",
|
||||||
|
] as const;
|
||||||
|
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
|
||||||
|
|
||||||
|
export const dashboardSortOrders = ["ascending", "descending"] as const;
|
||||||
|
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
|
||||||
@@ -60,6 +60,15 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
|
|||||||
pagespeed: "optionPagespeed",
|
pagespeed: "optionPagespeed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMonitorTypeLabel = (
|
||||||
|
type: MonitorType,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string => {
|
||||||
|
const monitorLabel = monitorTypeLabelKey[type];
|
||||||
|
return monitorLabel ? t(`pages.common.monitors.monitorTypes.${monitorLabel}`) : type;
|
||||||
|
};
|
||||||
|
export const MIN_MONITOR_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
export const MonitorIntervalOptions = [
|
export const MonitorIntervalOptions = [
|
||||||
{ value: 15000, labelKey: "fifteenSeconds" },
|
{ value: 15000, labelKey: "fifteenSeconds" },
|
||||||
{ value: 30000, labelKey: "thirtySeconds" },
|
{ value: 30000, labelKey: "thirtySeconds" },
|
||||||
@@ -160,6 +169,11 @@ export interface Monitor {
|
|||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
geoCheckInterval?: number;
|
geoCheckInterval?: number;
|
||||||
dockerLogsEnabled?: boolean;
|
dockerLogsEnabled?: boolean;
|
||||||
|
dockerAlertOnStopped?: boolean;
|
||||||
|
dockerAlertOnUnhealthy?: boolean;
|
||||||
|
dockerTlsCa?: string;
|
||||||
|
dockerTlsCert?: string;
|
||||||
|
dockerTlsKeySet?: boolean;
|
||||||
dnsServer?: string;
|
dnsServer?: string;
|
||||||
dnsRecordType?: DnsRecordType;
|
dnsRecordType?: DnsRecordType;
|
||||||
recentChecks: CheckSnapshot[];
|
recentChecks: CheckSnapshot[];
|
||||||
@@ -180,6 +194,11 @@ export interface MonitorsSummary {
|
|||||||
breachedMonitors: number;
|
breachedMonitors: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonitorTypeCount {
|
||||||
|
type: MonitorType;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitorsWithChecksResponse {
|
export interface MonitorsWithChecksResponse {
|
||||||
count: number;
|
count: number;
|
||||||
monitors: MonitorWithChecks[];
|
monitors: MonitorWithChecks[];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
|||||||
"teams",
|
"teams",
|
||||||
"telegram",
|
"telegram",
|
||||||
"pushover",
|
"pushover",
|
||||||
|
"signalgrid",
|
||||||
"twilio",
|
"twilio",
|
||||||
"ntfy",
|
"ntfy",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
||||||
export type DateRange = (typeof DateRanges)[number];
|
export type DateRange = (typeof DateRanges)[number];
|
||||||
|
|
||||||
|
export const SortOrders = ["asc", "desc"] as const;
|
||||||
|
export type SortOrder = (typeof SortOrders)[number];
|
||||||
@@ -47,3 +47,6 @@ export const getPercentage = (value: number, total: number) => {
|
|||||||
if (!value || !total) return 0;
|
if (!value || !total) return 0;
|
||||||
return (value / total) * 100;
|
return (value / total) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shown wherever a value is not available for the current data source.
|
||||||
|
export const PLACEHOLDER = "—";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,16 @@ import {
|
|||||||
type DockerLogLine,
|
type DockerLogLine,
|
||||||
} from "@/Types/Check";
|
} from "@/Types/Check";
|
||||||
|
|
||||||
|
export const getNextMonitorSort = (
|
||||||
|
field: string,
|
||||||
|
sortField: string,
|
||||||
|
sortOrder: SortOrder
|
||||||
|
): { field: string; order: SortOrder } => {
|
||||||
|
if (field !== sortField) return { field, order: "asc" };
|
||||||
|
if (sortOrder === "asc") return { field, order: "desc" };
|
||||||
|
return { field: "", order: "asc" };
|
||||||
|
};
|
||||||
|
|
||||||
export const getMonitorPath = (type: MonitorType): string => {
|
export const getMonitorPath = (type: MonitorType): string => {
|
||||||
const pathMap: Record<MonitorType, string> = {
|
const pathMap: Record<MonitorType, string> = {
|
||||||
http: "uptime",
|
http: "uptime",
|
||||||
@@ -20,7 +31,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
|||||||
websocket: "uptime",
|
websocket: "uptime",
|
||||||
dns: "uptime",
|
dns: "uptime",
|
||||||
unknown: "uptime",
|
unknown: "uptime",
|
||||||
docker: "docker",
|
docker: "docker/host",
|
||||||
hardware: "infrastructure",
|
hardware: "infrastructure",
|
||||||
pagespeed: "pagespeed",
|
pagespeed: "pagespeed",
|
||||||
};
|
};
|
||||||
@@ -40,6 +51,14 @@ 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",
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export const lightPalette = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
accent: colors.brandGreen,
|
accent: colors.brandGreen,
|
||||||
},
|
},
|
||||||
|
scrollbar: {
|
||||||
|
track: colors.rule200,
|
||||||
|
thumb: colors.brandGreen,
|
||||||
|
},
|
||||||
rowStatus: {
|
rowStatus: {
|
||||||
running: colors.green200,
|
running: colors.green200,
|
||||||
paused: colors.yellow200,
|
paused: colors.yellow200,
|
||||||
@@ -137,6 +141,10 @@ export const darkPalette = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
accent: colors.brandGreenLight,
|
accent: colors.brandGreenLight,
|
||||||
},
|
},
|
||||||
|
scrollbar: {
|
||||||
|
track: colors.ruleDark,
|
||||||
|
thumb: colors.brandGreen,
|
||||||
|
},
|
||||||
rowStatus: {
|
rowStatus: {
|
||||||
running: alpha(colors.brandGreen, 0.18),
|
running: alpha(colors.brandGreen, 0.18),
|
||||||
paused: alpha(colors.orange500, 0.18),
|
paused: alpha(colors.orange500, 0.18),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palet
|
|||||||
|
|
||||||
import type { Theme } from "@mui/material/styles";
|
import type { Theme } from "@mui/material/styles";
|
||||||
import type {} from "@mui/material/styles";
|
import type {} from "@mui/material/styles";
|
||||||
|
import { SCROLLBAR_SIZE } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
declare module "@mui/material/styles" {
|
declare module "@mui/material/styles" {
|
||||||
interface TypographyVariants {
|
interface TypographyVariants {
|
||||||
@@ -20,6 +21,7 @@ declare module "@mui/material/styles" {
|
|||||||
}
|
}
|
||||||
interface Palette {
|
interface Palette {
|
||||||
sidebar: { accent: string };
|
sidebar: { accent: string };
|
||||||
|
scrollbar: { track: string; thumb: string };
|
||||||
rowStatus: { running: string; paused: string };
|
rowStatus: { running: string; paused: string };
|
||||||
chart: {
|
chart: {
|
||||||
phases: {
|
phases: {
|
||||||
@@ -34,6 +36,7 @@ declare module "@mui/material/styles" {
|
|||||||
}
|
}
|
||||||
interface PaletteOptions {
|
interface PaletteOptions {
|
||||||
sidebar?: { accent: string };
|
sidebar?: { accent: string };
|
||||||
|
scrollbar?: { track: string; thumb: string };
|
||||||
rowStatus?: { running: string; paused: string };
|
rowStatus?: { running: string; paused: string };
|
||||||
chart?: {
|
chart?: {
|
||||||
phases: {
|
phases: {
|
||||||
@@ -112,6 +115,27 @@ export const theme = (mode: string, palette: any) =>
|
|||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: (theme) => ({
|
||||||
|
"*": {
|
||||||
|
scrollbarWidth: "auto",
|
||||||
|
scrollbarColor: `${theme.palette.scrollbar.thumb} ${theme.palette.scrollbar.track}`,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar": {
|
||||||
|
width: SCROLLBAR_SIZE,
|
||||||
|
height: SCROLLBAR_SIZE,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar-track": {
|
||||||
|
background: theme.palette.scrollbar.track,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar-thumb": {
|
||||||
|
background: theme.palette.scrollbar.thumb,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar-button": { display: "none" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
MuiTouchRipple: {
|
MuiTouchRipple: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const LAYOUT = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
|
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
|
||||||
|
export const SCROLLBAR_SIZE = 16;
|
||||||
export const HOVER = {
|
export const HOVER = {
|
||||||
DARKEN: 0.06, // This is a coefficient for darkening function
|
DARKEN: 0.06, // This is a coefficient for darkening function
|
||||||
ROW: 0.025, // Overlay alpha for hoverable rows and cards
|
ROW: 0.025, // Overlay alpha for hoverable rows and cards
|
||||||
|
|||||||
@@ -104,9 +104,52 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => {
|
|||||||
return formatted;
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => {
|
type DurationUnit = "day" | "hour" | "minute" | "second";
|
||||||
if (verbose) {
|
|
||||||
return prettyMilliseconds(ms, { verbose: true });
|
interface FormatDurationOptions {
|
||||||
|
long?: boolean;
|
||||||
|
relative?: boolean;
|
||||||
|
hasSpace?: boolean;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
|
||||||
|
{ unit: "day", milliseconds: MS_PER_DAY },
|
||||||
|
{ unit: "hour", milliseconds: MS_PER_HOUR },
|
||||||
|
{ unit: "minute", milliseconds: MS_PER_MINUTE },
|
||||||
|
{ unit: "second", milliseconds: MS_PER_SECOND },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
|
||||||
|
|
||||||
|
const getDurationUnit = (ms: number) =>
|
||||||
|
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
|
||||||
|
FALLBACK_DURATION_UNIT;
|
||||||
|
|
||||||
|
const getExactDurationUnit = (ms: number) =>
|
||||||
|
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
|
||||||
|
FALLBACK_DURATION_UNIT;
|
||||||
|
|
||||||
|
export const formatDuration = (
|
||||||
|
ms: number,
|
||||||
|
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
|
||||||
|
): string => {
|
||||||
|
if (relative) {
|
||||||
|
const { unit, milliseconds } = getDurationUnit(ms);
|
||||||
|
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
|
||||||
|
return new Intl.RelativeTimeFormat(locale, {
|
||||||
|
numeric: "always",
|
||||||
|
style: long ? "long" : "narrow",
|
||||||
|
}).format(-value, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (long) {
|
||||||
|
const { unit, milliseconds } = getExactDurationUnit(ms);
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: "unit",
|
||||||
|
unit,
|
||||||
|
unitDisplay: "long",
|
||||||
|
}).format(ms / milliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = prettyMilliseconds(ms, {
|
const formatted = prettyMilliseconds(ms, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HttpMethods,
|
HttpMethods,
|
||||||
PageSpeedStrategies,
|
PageSpeedStrategies,
|
||||||
ProxyModes,
|
ProxyModes,
|
||||||
|
MIN_MONITOR_INTERVAL_MS,
|
||||||
type MonitorType,
|
type MonitorType,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||||
@@ -27,7 +28,8 @@ const baseSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
interval: z
|
interval: z
|
||||||
.number()
|
.number()
|
||||||
.min(15000, "Interval must be at least 15 seconds")
|
.int("Interval must be a whole number of milliseconds")
|
||||||
|
.min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
|
||||||
.register(monitorStepRegistry, { step: 1 }),
|
.register(monitorStepRegistry, { step: 1 }),
|
||||||
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
||||||
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
||||||
@@ -108,11 +110,50 @@ const portSchema = baseSchema.extend({
|
|||||||
.max(65535, "Port must be at most 65535"),
|
.max(65535, "Port must be at most 65535"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Docker monitor schema
|
// Docker monitor schema. The URL forms mirror the Docker source helpers 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() ?? "");
|
||||||
|
|
||||||
|
export const isCaptureDockerUrl = (url: string | undefined): boolean => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url?.trim() ?? "");
|
||||||
|
const pathname = parsed.pathname.replace(/\/+$/, "");
|
||||||
|
return (
|
||||||
|
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
||||||
|
pathname.endsWith("/metrics/docker")
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const dockerSchema = baseSchema.extend({
|
const dockerSchema = baseSchema.extend({
|
||||||
type: z.literal("docker"),
|
type: z.literal("docker"),
|
||||||
url: z.string().min(1, "Docker host URL is required"),
|
url: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Docker host URL is required")
|
||||||
|
.refine(
|
||||||
|
(url) =>
|
||||||
|
dockerSocketUrlRegex.test(url.trim()) ||
|
||||||
|
isDockerTlsUrl(url) ||
|
||||||
|
isCaptureDockerUrl(url),
|
||||||
|
"Docker host must be a socket path, a TLS daemon URL, or a Capture /metrics/docker endpoint"
|
||||||
|
),
|
||||||
|
secret: z.string(),
|
||||||
|
ignoreTlsErrors: z.boolean(),
|
||||||
|
dockerTlsCa: z.string(),
|
||||||
|
dockerTlsCert: z.string(),
|
||||||
|
// blank on edit keeps the stored key.
|
||||||
|
dockerTlsKey: z.string(),
|
||||||
|
// Read only mirror of the server flag, stripped on submit
|
||||||
|
dockerTlsKeySet: z.boolean(),
|
||||||
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
|
dockerAlertOnStopped: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Game server monitor schema
|
// Game server monitor schema
|
||||||
@@ -217,7 +258,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
|
|||||||
dnsSchema,
|
dnsSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mirrors the server's refineProxySelection (monitorValidation.ts)
|
// Mirrors the server's refineProxySelection and refineDockerTls
|
||||||
|
// (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({
|
||||||
@@ -226,6 +268,38 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
|
|||||||
message: "A proxy must be selected when proxy mode is custom",
|
message: "A proxy must be selected when proxy mode is custom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === "docker" && isCaptureDockerUrl(data.url) && !data.secret.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["secret"],
|
||||||
|
message: "Capture API secret is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
|
||||||
|
if (!data.dockerTlsCert.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["dockerTlsCert"],
|
||||||
|
message: "Client certificate is required for a TLS Docker host",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["dockerTlsKey"],
|
||||||
|
message: "Client key is required for a TLS Docker host",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["dockerTlsCa"],
|
||||||
|
message: "CA certificate is required unless TLS errors are ignored",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ 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"),
|
||||||
@@ -95,6 +101,7 @@ 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: 536 KiB After Width: | Height: | Size: 286 KiB |
+3
-141
@@ -182,7 +182,6 @@
|
|||||||
"maintenance": "الصيانة",
|
"maintenance": "الصيانة",
|
||||||
"logs": "السجلات",
|
"logs": "السجلات",
|
||||||
"settings": "الإعدادات",
|
"settings": "الإعدادات",
|
||||||
"dashboard": "لوحة المعلومات",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "المراقبة",
|
"monitoring": "المراقبة",
|
||||||
"alerting": "التنبيهات",
|
"alerting": "التنبيهات",
|
||||||
@@ -230,146 +229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "لوحة المعلومات",
|
|
||||||
"description": "اطّلع على كل أنواع المراقبات في مكان واحد ولاحظ ما يحتاج إلى انتباه."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "تعذّر تحميل هذه البطاقة."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "حالة المراقبات",
|
|
||||||
"empty": "لا توجد مراقبات بعد.",
|
|
||||||
"upOfTotal": "من {{total}} تعمل"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "متوقفة حاليًا",
|
|
||||||
"empty": "جميع المراقبات تعمل. لا توجد حوادث مفتوحة.",
|
|
||||||
"count": "{{count}} تحتاج إلى انتباه"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "المراقبات حسب النوع",
|
|
||||||
"empty": "لا توجد مراقبات بعد.",
|
|
||||||
"down": "{{count}} متوقفة"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "الحوادث الأخيرة",
|
|
||||||
"empty": "لا توجد حوادث — جميع الأنظمة سليمة.",
|
|
||||||
"active": "{{count}} مفتوحة",
|
|
||||||
"ongoing": "جارية",
|
|
||||||
"manual": "يدوي",
|
|
||||||
"unknownMonitor": "مراقبة غير معروفة"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "الفحوصات الفاشلة",
|
|
||||||
"empty": "لا توجد فحوصات بعد.",
|
|
||||||
"ratio": "{{failed}} من {{total}} في آخر يوم"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "خادم Checkmate",
|
|
||||||
"memory": "الذاكرة",
|
|
||||||
"eventLoop": "تأخير حلقة الأحداث",
|
|
||||||
"uptime": "مدة التشغيل",
|
|
||||||
"database": "قاعدة البيانات",
|
|
||||||
"readsWrites": "{{reads}} قراءة/ث · {{writes}} كتابة/ث"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "أعلى {{shown}} من {{total}}",
|
|
||||||
"notEnough": "لا توجد مراقبات كافية للترتيب."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "أبطأ المراقبات"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "أدنى وقت تشغيل"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "المراقبات حسب المجموعة",
|
|
||||||
"empty": "لا توجد مجموعات بعد. تُحدَّد المجموعات لكل مراقبة."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "أكثر الخوادم انشغالًا",
|
|
||||||
"empty": "لا توجد مراقبات بنية تحتية بعد.",
|
|
||||||
"cpu": "المعالج",
|
|
||||||
"memory": "الذاكرة",
|
|
||||||
"disk": "القرص"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "إحصائيات الحوادث",
|
|
||||||
"openNow": "مفتوحة الآن",
|
|
||||||
"avgResolution": "متوسط وقت الحل",
|
|
||||||
"hours": "{{hours}} س",
|
|
||||||
"mostIncidents": "الأكثر حوادث",
|
|
||||||
"total": "الإجمالي",
|
|
||||||
"resolved": "تم حلها",
|
|
||||||
"automatic": "تلقائي",
|
|
||||||
"manual": "يدوي",
|
|
||||||
"autoManual": "{{automatic}} تلقائي · {{manual}} يدوي"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "قنوات الإشعارات",
|
|
||||||
"empty": "لا توجد قنوات بعد."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "نوافذ الصيانة",
|
|
||||||
"empty": "لا توجد صيانة مجدولة.",
|
|
||||||
"runningNow": "قيد التشغيل",
|
|
||||||
"next": "النافذة التالية"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "صفحات الحالة",
|
|
||||||
"empty": "لا توجد صفحات حالة بعد.",
|
|
||||||
"monitorCount": "{{count}} مراقبة",
|
|
||||||
"published": "منشورة",
|
|
||||||
"draft": "مسودة"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "طابور الفحوصات",
|
|
||||||
"healthy": "جميع المهام البالغ عددها {{jobs}} تسير وفق الجدول على {{workers}} عامل.",
|
|
||||||
"failing": "المهام الفاشلة",
|
|
||||||
"scheduled": "المهام المجدولة",
|
|
||||||
"runningNow": "قيد التشغيل"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "الأخطاء الأخيرة",
|
|
||||||
"empty": "لم تُسجَّل أخطاء.",
|
|
||||||
"count": "{{count}} أخطاء",
|
|
||||||
"warnings": "التحذيرات"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "أعضاء الفريق",
|
|
||||||
"members": "{{count}} عضو",
|
|
||||||
"unverified": "غير مُوثَّقين"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "تحرير البطاقات",
|
|
||||||
"title": "بطاقات لوحة المعلومات",
|
|
||||||
"done": "تم",
|
|
||||||
"reset": "إعادة التعيين إلى الافتراضي",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "وقت التشغيل",
|
|
||||||
"infrastructure": "البنية التحتية",
|
|
||||||
"incidents": "الحوادث",
|
|
||||||
"notifications": "الإشعارات",
|
|
||||||
"maintenance": "الصيانة",
|
|
||||||
"statusPages": "صفحات الحالة",
|
|
||||||
"checks": "الفحوصات",
|
|
||||||
"logs": "النظام",
|
|
||||||
"settings": "الإعدادات"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "لا توجد بطاقات في لوحة المعلومات",
|
|
||||||
"description": "اختر البطاقات التي تريد رؤيتها. يُحفظ اختيارك في هذا المتصفح."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "نقل لأعلى",
|
|
||||||
"moveDown": "نقل لأسفل"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "يا إلهي! لقد أسقطت السوشي!",
|
"title": "يا إلهي! لقد أسقطت السوشي!",
|
||||||
"subtitle": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه."
|
"subtitle": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه."
|
||||||
@@ -1518,6 +1377,9 @@
|
|||||||
"maintenance": "صيانة",
|
"maintenance": "صيانة",
|
||||||
"paused": "متوقف مؤقتاً",
|
"paused": "متوقف مؤقتاً",
|
||||||
"up": "قيد التشغيل"
|
"up": "قيد التشغيل"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "فشل تحميل الحوادث."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
+3
-141
@@ -190,7 +190,6 @@
|
|||||||
"maintenance": "Manteniment",
|
"maintenance": "Manteniment",
|
||||||
"logs": "Registres",
|
"logs": "Registres",
|
||||||
"settings": "Configuració",
|
"settings": "Configuració",
|
||||||
"dashboard": "Tauler",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "Monitoratge",
|
"monitoring": "Monitoratge",
|
||||||
"alerting": "Alertes",
|
"alerting": "Alertes",
|
||||||
@@ -230,146 +229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "Tauler",
|
|
||||||
"description": "Vegeu cada tipus de monitor en un sol lloc i detecteu què necessita atenció."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "No s'ha pogut carregar aquesta targeta."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "Estat dels monitors",
|
|
||||||
"empty": "Encara no hi ha monitors.",
|
|
||||||
"upOfTotal": "de {{total}} actius"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "Actualment caiguts",
|
|
||||||
"empty": "Tots els monitors estan actius. Cap incident obert.",
|
|
||||||
"count": "{{count}} requereixen atenció"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "Monitors per tipus",
|
|
||||||
"empty": "Encara no hi ha monitors.",
|
|
||||||
"down": "{{count}} caiguts"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "Incidents recents",
|
|
||||||
"empty": "Cap incident — tots els sistemes sans.",
|
|
||||||
"active": "{{count}} oberts",
|
|
||||||
"ongoing": "En curs",
|
|
||||||
"manual": "Manual",
|
|
||||||
"unknownMonitor": "Monitor desconegut"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "Comprovacions fallides",
|
|
||||||
"empty": "Encara no hi ha comprovacions.",
|
|
||||||
"ratio": "{{failed}} de {{total}} el darrer dia"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "Servidor de Checkmate",
|
|
||||||
"memory": "Memòria",
|
|
||||||
"eventLoop": "Retard del bucle d'esdeveniments",
|
|
||||||
"uptime": "Temps actiu",
|
|
||||||
"database": "Base de dades",
|
|
||||||
"readsWrites": "{{reads}} lectures/s · {{writes}} escriptures/s"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "Top {{shown}} de {{total}}",
|
|
||||||
"notEnough": "No hi ha prou monitors per fer una classificació."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "Monitors més lents"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "Temps en línia més baix"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "Monitors per grup",
|
|
||||||
"empty": "Encara no hi ha grups. Els grups es defineixen per monitor."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "Servidors més carregats",
|
|
||||||
"empty": "Encara no hi ha monitors d'infraestructura.",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"memory": "Memòria",
|
|
||||||
"disk": "Disc"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "Estadístiques d'incidents",
|
|
||||||
"openNow": "Oberts ara",
|
|
||||||
"avgResolution": "Temps mitjà de resolució",
|
|
||||||
"hours": "{{hours}} h",
|
|
||||||
"mostIncidents": "Més incidents",
|
|
||||||
"total": "total",
|
|
||||||
"resolved": "Resolts",
|
|
||||||
"automatic": "Automàtica",
|
|
||||||
"manual": "Manual",
|
|
||||||
"autoManual": "{{automatic}} automàtiques · {{manual}} manuals"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "Canals de notificació",
|
|
||||||
"empty": "Encara no hi ha canals."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "Finestres de manteniment",
|
|
||||||
"empty": "Cap manteniment programat.",
|
|
||||||
"runningNow": "En execució",
|
|
||||||
"next": "Propera finestra"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "Pàgines d'estat",
|
|
||||||
"empty": "Encara no hi ha pàgines d'estat.",
|
|
||||||
"monitorCount": "{{count}} monitors",
|
|
||||||
"published": "Publicada",
|
|
||||||
"draft": "Esborrany"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "Cua de comprovacions",
|
|
||||||
"healthy": "Les {{jobs}} tasques van segons el previst en {{workers}} worker(s).",
|
|
||||||
"failing": "Tasques fallides",
|
|
||||||
"scheduled": "Tasques programades",
|
|
||||||
"runningNow": "En execució"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "Errors recents",
|
|
||||||
"empty": "Cap error registrat.",
|
|
||||||
"count": "{{count}} errors",
|
|
||||||
"warnings": "Advertiments"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "Membres de l'equip",
|
|
||||||
"members": "{{count}} membres",
|
|
||||||
"unverified": "Sense verificar"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "Edita les targetes",
|
|
||||||
"title": "Targetes del tauler",
|
|
||||||
"done": "Fet",
|
|
||||||
"reset": "Restableix els valors predeterminats",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "Temps en línia",
|
|
||||||
"infrastructure": "Infraestructura",
|
|
||||||
"incidents": "Incidents",
|
|
||||||
"notifications": "Notificacions",
|
|
||||||
"maintenance": "Manteniment",
|
|
||||||
"statusPages": "Pàgines d'estat",
|
|
||||||
"checks": "Comprovacions",
|
|
||||||
"logs": "Sistema",
|
|
||||||
"settings": "Configuració"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "No hi ha targetes al vostre tauler",
|
|
||||||
"description": "Trieu les targetes que voleu veure. La vostra selecció es recorda en aquest navegador."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "Mou amunt",
|
|
||||||
"moveDown": "Mou avall"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Oh no! Has deixat caure el teu sushi!",
|
"title": "Oh no! Has deixat caure el teu sushi!",
|
||||||
"subtitle": "O bé la URL no existeix, o bé no tens accés."
|
"subtitle": "O bé la URL no existeix, o bé no tens accés."
|
||||||
@@ -1518,6 +1377,9 @@
|
|||||||
"uptime": {
|
"uptime": {
|
||||||
"title": "Temps en línia",
|
"title": "Temps en línia",
|
||||||
"responseTime": "Temps de resposta"
|
"responseTime": "Temps de resposta"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "No s'han pogut carregar les incidències."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
+3
-141
@@ -182,7 +182,6 @@
|
|||||||
"maintenance": "Údržba",
|
"maintenance": "Údržba",
|
||||||
"logs": "Záznamy",
|
"logs": "Záznamy",
|
||||||
"settings": "Nastavení",
|
"settings": "Nastavení",
|
||||||
"dashboard": "Přehled",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "Monitorování",
|
"monitoring": "Monitorování",
|
||||||
"alerting": "Upozornění",
|
"alerting": "Upozornění",
|
||||||
@@ -230,146 +229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "Přehled",
|
|
||||||
"description": "Zobrazte každý typ monitoru na jednom místě a poznejte, co vyžaduje pozornost."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "Tuto kartu se nepodařilo načíst."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "Stav monitorů",
|
|
||||||
"empty": "Zatím žádné monitory.",
|
|
||||||
"upOfTotal": "z {{total}} běží"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "Aktuálně nedostupné",
|
|
||||||
"empty": "Všechny monitory běží. Žádné otevřené incidenty.",
|
|
||||||
"count": "{{count}} vyžaduje pozornost"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "Monitory podle typu",
|
|
||||||
"empty": "Zatím žádné monitory.",
|
|
||||||
"down": "{{count}} nedostupných"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "Nedávné incidenty",
|
|
||||||
"empty": "Žádné incidenty — všechny systémy v pořádku.",
|
|
||||||
"active": "{{count}} otevřených",
|
|
||||||
"ongoing": "Probíhá",
|
|
||||||
"manual": "Ručně",
|
|
||||||
"unknownMonitor": "Neznámý monitor"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "Neúspěšné kontroly",
|
|
||||||
"empty": "Zatím žádné kontroly.",
|
|
||||||
"ratio": "{{failed}} z {{total}} za poslední den"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "Server Checkmate",
|
|
||||||
"memory": "Paměť",
|
|
||||||
"eventLoop": "Zpoždění event loop",
|
|
||||||
"uptime": "Doba běhu",
|
|
||||||
"database": "Databáze",
|
|
||||||
"readsWrites": "{{reads}} čtení/s · {{writes}} zápisů/s"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "Top {{shown}} z {{total}}",
|
|
||||||
"notEnough": "Zatím není dost monitorů pro žebříček."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "Nejpomalejší monitory"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "Nejnižší dostupnost"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "Monitory podle skupiny",
|
|
||||||
"empty": "Zatím žádné skupiny. Skupiny se nastavují u monitoru."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "Nejvytíženější servery",
|
|
||||||
"empty": "Zatím žádné monitory infrastruktury.",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"memory": "Paměť",
|
|
||||||
"disk": "Disk"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "Statistiky incidentů",
|
|
||||||
"openNow": "Nyní otevřené",
|
|
||||||
"avgResolution": "Průměrná doba vyřešení",
|
|
||||||
"hours": "{{hours}} h",
|
|
||||||
"mostIncidents": "Nejvíce incidentů",
|
|
||||||
"total": "celkem",
|
|
||||||
"resolved": "Vyřešeno",
|
|
||||||
"automatic": "Automaticky",
|
|
||||||
"manual": "Ručně",
|
|
||||||
"autoManual": "{{automatic}} automaticky · {{manual}} ručně"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "Kanály oznámení",
|
|
||||||
"empty": "Zatím žádné kanály."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "Okna údržby",
|
|
||||||
"empty": "Žádná naplánovaná údržba.",
|
|
||||||
"runningNow": "Právě probíhá",
|
|
||||||
"next": "Další okno"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "Stavové stránky",
|
|
||||||
"empty": "Zatím žádné stavové stránky.",
|
|
||||||
"monitorCount": "{{count}} monitorů",
|
|
||||||
"published": "Publikováno",
|
|
||||||
"draft": "Koncept"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "Fronta kontrol",
|
|
||||||
"healthy": "Všech {{jobs}} úloh běží podle plánu na {{workers}} workerech.",
|
|
||||||
"failing": "Selhávající úlohy",
|
|
||||||
"scheduled": "Naplánované úlohy",
|
|
||||||
"runningNow": "Právě probíhá"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "Nedávné chyby",
|
|
||||||
"empty": "Žádné zaznamenané chyby.",
|
|
||||||
"count": "{{count}} chyb",
|
|
||||||
"warnings": "Varování"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "Členové týmu",
|
|
||||||
"members": "{{count}} členů",
|
|
||||||
"unverified": "Neověření"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "Upravit karty",
|
|
||||||
"title": "Karty přehledu",
|
|
||||||
"done": "Hotovo",
|
|
||||||
"reset": "Obnovit výchozí",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "Dostupnost",
|
|
||||||
"infrastructure": "Infrastruktura",
|
|
||||||
"incidents": "Incidenty",
|
|
||||||
"notifications": "Notifikace",
|
|
||||||
"maintenance": "Údržba",
|
|
||||||
"statusPages": "Stavové stránky",
|
|
||||||
"checks": "Kontroly",
|
|
||||||
"logs": "Systém",
|
|
||||||
"settings": "Nastavení"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "Ve vašem přehledu nejsou žádné karty",
|
|
||||||
"description": "Vyberte karty, které chcete zobrazit. Váš výběr se uchová v tomto prohlížeči."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "Posunout nahoru",
|
|
||||||
"moveDown": "Posunout dolů"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Ale ne! Upustili jste své sushi!",
|
"title": "Ale ne! Upustili jste své sushi!",
|
||||||
"subtitle": "Buď tato URL neexistuje, nebo k ní nemáte přístup."
|
"subtitle": "Buď tato URL neexistuje, nebo k ní nemáte přístup."
|
||||||
@@ -1518,6 +1377,9 @@
|
|||||||
"maintenance": "Údržba",
|
"maintenance": "Údržba",
|
||||||
"paused": "Pozastaveno",
|
"paused": "Pozastaveno",
|
||||||
"up": "V provozu"
|
"up": "V provozu"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Nepodařilo se načíst incidenty."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
+4
-141
@@ -182,7 +182,6 @@
|
|||||||
"maintenance": "Wartung",
|
"maintenance": "Wartung",
|
||||||
"logs": "Protokolle",
|
"logs": "Protokolle",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"dashboard": "Dashboard",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "Überwachung",
|
"monitoring": "Überwachung",
|
||||||
"alerting": "Benachrichtigungen",
|
"alerting": "Benachrichtigungen",
|
||||||
@@ -230,146 +229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "Dashboard",
|
|
||||||
"description": "Sehen Sie jeden Monitortyp an einem Ort und erkennen Sie, was Aufmerksamkeit braucht."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "Diese Karte konnte nicht geladen werden."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "Monitor-Status",
|
|
||||||
"empty": "Noch keine Monitore.",
|
|
||||||
"upOfTotal": "von {{total}} online"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "Derzeit offline",
|
|
||||||
"empty": "Alle Monitore sind online. Keine offenen Vorfälle.",
|
|
||||||
"count": "{{count}} benötigen Aufmerksamkeit"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "Monitore nach Typ",
|
|
||||||
"empty": "Noch keine Monitore.",
|
|
||||||
"down": "{{count}} offline"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "Letzte Vorfälle",
|
|
||||||
"empty": "Keine Vorfälle — alle Systeme gesund.",
|
|
||||||
"active": "{{count}} offen",
|
|
||||||
"ongoing": "Laufend",
|
|
||||||
"manual": "Manuell",
|
|
||||||
"unknownMonitor": "Unbekannter Monitor"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "Fehlgeschlagene Prüfungen",
|
|
||||||
"empty": "Noch keine Prüfungen.",
|
|
||||||
"ratio": "{{failed}} von {{total}} am letzten Tag"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "Checkmate-Server",
|
|
||||||
"memory": "Arbeitsspeicher",
|
|
||||||
"eventLoop": "Event-Loop-Verzögerung",
|
|
||||||
"uptime": "Laufzeit",
|
|
||||||
"database": "Datenbank",
|
|
||||||
"readsWrites": "{{reads}} Lesevorgänge/s · {{writes}} Schreibvorgänge/s"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "Top {{shown}} von {{total}}",
|
|
||||||
"notEnough": "Noch nicht genug Monitore für ein Ranking."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "Langsamste Monitore"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "Niedrigste Uptime"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "Monitore nach Gruppe",
|
|
||||||
"empty": "Noch keine Gruppen. Gruppen werden pro Monitor gesetzt."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "Ausgelastetste Server",
|
|
||||||
"empty": "Noch keine Infrastruktur-Monitore.",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"memory": "Arbeitsspeicher",
|
|
||||||
"disk": "Festplatte"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "Vorfallstatistik",
|
|
||||||
"openNow": "Jetzt offen",
|
|
||||||
"avgResolution": "Durchschnittliche Lösungszeit",
|
|
||||||
"hours": "{{hours}} Std.",
|
|
||||||
"mostIncidents": "Meiste Vorfälle",
|
|
||||||
"total": "gesamt",
|
|
||||||
"resolved": "Gelöst",
|
|
||||||
"automatic": "Automatisch",
|
|
||||||
"manual": "Manuell",
|
|
||||||
"autoManual": "{{automatic}} automatisch · {{manual}} manuell"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "Benachrichtigungskanäle",
|
|
||||||
"empty": "Noch keine Kanäle."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "Wartungsfenster",
|
|
||||||
"empty": "Keine Wartung geplant.",
|
|
||||||
"runningNow": "Läuft gerade",
|
|
||||||
"next": "Nächstes Fenster"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "Statusseiten",
|
|
||||||
"empty": "Noch keine Statusseiten.",
|
|
||||||
"monitorCount": "{{count}} Monitore",
|
|
||||||
"published": "Veröffentlicht",
|
|
||||||
"draft": "Entwurf"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "Prüfungs-Warteschlange",
|
|
||||||
"healthy": "Alle {{jobs}} Jobs planmäßig auf {{workers}} Worker(n).",
|
|
||||||
"failing": "Fehlgeschlagene Jobs",
|
|
||||||
"scheduled": "Geplante Jobs",
|
|
||||||
"runningNow": "Läuft gerade"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "Letzte Fehler",
|
|
||||||
"empty": "Keine Fehler protokolliert.",
|
|
||||||
"count": "{{count}} Fehler",
|
|
||||||
"warnings": "Warnungen"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "Teammitglieder",
|
|
||||||
"members": "{{count}} Mitglieder",
|
|
||||||
"unverified": "Nicht verifiziert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "Karten bearbeiten",
|
|
||||||
"title": "Dashboard-Karten",
|
|
||||||
"done": "Fertig",
|
|
||||||
"reset": "Auf Standard zurücksetzen",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "Uptime",
|
|
||||||
"infrastructure": "Infrastruktur",
|
|
||||||
"incidents": "Vorfälle",
|
|
||||||
"notifications": "Benachrichtigungen",
|
|
||||||
"maintenance": "Wartung",
|
|
||||||
"statusPages": "Statusseiten",
|
|
||||||
"checks": "Prüfungen",
|
|
||||||
"logs": "System",
|
|
||||||
"settings": "Einstellungen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "Keine Karten auf Ihrem Dashboard",
|
|
||||||
"description": "Wählen Sie die Karten, die Sie sehen möchten. Ihre Auswahl wird in diesem Browser gespeichert."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "Nach oben",
|
|
||||||
"moveDown": "Nach unten"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Oh nein! Sie haben Ihr Sushi fallen lassen!",
|
"title": "Oh nein! Sie haben Ihr Sushi fallen lassen!",
|
||||||
"subtitle": "Entweder existiert die URL nicht oder Sie haben keinen Zugriff darauf."
|
"subtitle": "Entweder existiert die URL nicht oder Sie haben keinen Zugriff darauf."
|
||||||
@@ -589,6 +448,7 @@
|
|||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Aktiv seit",
|
"activeFor": "Aktiv seit",
|
||||||
|
"checkingEvery": "Check alle {{interval}}",
|
||||||
"certificateExpiry": "Zertifikatablauf",
|
"certificateExpiry": "Zertifikatablauf",
|
||||||
"lastCheck": "Letzter Check",
|
"lastCheck": "Letzter Check",
|
||||||
"lastResponseTime": "Letzte Antwortzeit",
|
"lastResponseTime": "Letzte Antwortzeit",
|
||||||
@@ -1518,6 +1378,9 @@
|
|||||||
"maintenance": "Wartung",
|
"maintenance": "Wartung",
|
||||||
"paused": "Pausiert",
|
"paused": "Pausiert",
|
||||||
"up": "Betriebsbereit"
|
"up": "Betriebsbereit"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Vorfälle konnten nicht geladen werden."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
+99
-150
@@ -80,7 +80,9 @@
|
|||||||
"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",
|
||||||
@@ -219,13 +221,13 @@
|
|||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"dashboard": "Dashboard",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"alerting": "Alerting",
|
"alerting": "Alerting",
|
||||||
"reporting": "Reporting",
|
"reporting": "Reporting",
|
||||||
"configuration": "Configuration"
|
"configuration": "Configuration"
|
||||||
}
|
},
|
||||||
|
"dashboard": "Dashboard"
|
||||||
},
|
},
|
||||||
"bottomMenu": {
|
"bottomMenu": {
|
||||||
"support": "Support",
|
"support": "Support",
|
||||||
@@ -259,146 +261,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "Dashboard",
|
|
||||||
"description": "See every monitor type in one place and spot what needs attention."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "Could not load this card."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "Monitor status",
|
|
||||||
"empty": "No monitors yet.",
|
|
||||||
"upOfTotal": "of {{total}} up"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "Currently down",
|
|
||||||
"empty": "All monitors are up. No incidents open.",
|
|
||||||
"count": "{{count}} needing attention"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "Monitors by type",
|
|
||||||
"empty": "No monitors yet.",
|
|
||||||
"down": "{{count}} down"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "Recent incidents",
|
|
||||||
"empty": "No incidents — all systems healthy.",
|
|
||||||
"active": "{{count}} open",
|
|
||||||
"ongoing": "Ongoing",
|
|
||||||
"manual": "Manual",
|
|
||||||
"unknownMonitor": "Unknown monitor"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "Failed checks",
|
|
||||||
"empty": "No checks yet.",
|
|
||||||
"ratio": "{{failed}} of {{total}} in the last day"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "Checkmate server",
|
|
||||||
"memory": "Memory",
|
|
||||||
"eventLoop": "Event loop delay",
|
|
||||||
"uptime": "Uptime",
|
|
||||||
"database": "Database",
|
|
||||||
"readsWrites": "{{reads}} reads/s · {{writes}} writes/s"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "Top {{shown}} of {{total}}",
|
|
||||||
"notEnough": "Not enough monitors to rank yet."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "Slowest monitors"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "Lowest uptime"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "Monitors by group",
|
|
||||||
"empty": "No groups yet. Groups are set per monitor."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "Busiest servers",
|
|
||||||
"empty": "No infrastructure monitors yet.",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"memory": "Memory",
|
|
||||||
"disk": "Disk"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "Incident stats",
|
|
||||||
"openNow": "Open now",
|
|
||||||
"avgResolution": "Average time to resolve",
|
|
||||||
"hours": "{{hours}} h",
|
|
||||||
"mostIncidents": "Most incidents",
|
|
||||||
"total": "total",
|
|
||||||
"resolved": "Resolved",
|
|
||||||
"automatic": "Automatic",
|
|
||||||
"manual": "Manual",
|
|
||||||
"autoManual": "{{automatic}} automatic · {{manual}} manual"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "Notification channels",
|
|
||||||
"empty": "No channels yet."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "Maintenance windows",
|
|
||||||
"empty": "No maintenance scheduled.",
|
|
||||||
"runningNow": "Running now",
|
|
||||||
"next": "Next window"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "Status pages",
|
|
||||||
"empty": "No status pages yet.",
|
|
||||||
"monitorCount": "{{count}} monitors",
|
|
||||||
"published": "Published",
|
|
||||||
"draft": "Draft"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "Check queue",
|
|
||||||
"healthy": "All {{jobs}} jobs on schedule across {{workers}} worker(s).",
|
|
||||||
"failing": "Failing jobs",
|
|
||||||
"scheduled": "Scheduled jobs",
|
|
||||||
"runningNow": "Running now"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "Recent errors",
|
|
||||||
"empty": "No errors logged.",
|
|
||||||
"count": "{{count}} errors",
|
|
||||||
"warnings": "Warnings"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "Team members",
|
|
||||||
"members": "{{count}} members",
|
|
||||||
"unverified": "Unverified"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "Edit cards",
|
|
||||||
"title": "Dashboard cards",
|
|
||||||
"done": "Done",
|
|
||||||
"reset": "Reset to defaults",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "Uptime",
|
|
||||||
"infrastructure": "Infrastructure",
|
|
||||||
"incidents": "Incidents",
|
|
||||||
"notifications": "Notifications",
|
|
||||||
"maintenance": "Maintenance",
|
|
||||||
"statusPages": "Status pages",
|
|
||||||
"checks": "Checks",
|
|
||||||
"logs": "System",
|
|
||||||
"settings": "Settings"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "No cards on your dashboard",
|
|
||||||
"description": "Choose the cards you want to see. Your selection is remembered in this browser."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "Move up",
|
|
||||||
"moveDown": "Move down"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxies": {
|
"proxies": {
|
||||||
"header": {
|
"header": {
|
||||||
"title": "Proxies",
|
"title": "Proxies",
|
||||||
@@ -686,6 +548,7 @@
|
|||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Active for",
|
"activeFor": "Active for",
|
||||||
|
"checkingEvery": "Checking every {{interval}}",
|
||||||
"certificateExpiry": "Certificate expiry",
|
"certificateExpiry": "Certificate expiry",
|
||||||
"domainExpiry": "Domain expiry",
|
"domainExpiry": "Domain expiry",
|
||||||
"lastCheck": "Last check",
|
"lastCheck": "Last check",
|
||||||
@@ -694,7 +557,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"down": "down",
|
"down": "down",
|
||||||
"breached": "threshold exceeded",
|
"breached": "alert",
|
||||||
"initializing": "initializing",
|
"initializing": "initializing",
|
||||||
"maintenance": "maintenance",
|
"maintenance": "maintenance",
|
||||||
"paused": "paused",
|
"paused": "paused",
|
||||||
@@ -857,7 +720,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. Every container on the host is monitored.",
|
"docker": "Enter a Docker socket such as unix:///var/run/docker.sock, a remote daemon such as tcp://host:2376 with TLS client certificates, or a Capture endpoint ending in /metrics/docker with its authorization secret. Every container on the host is monitored.",
|
||||||
"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.",
|
||||||
@@ -964,6 +827,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dockerAlerts": {
|
||||||
|
"title": "Container alerts",
|
||||||
|
"description": "Mark this host as breached while any container on it is stopped or failing its health check, and notify on the change. Containers that exit with code 0 do not count as stopped. Capture does not report exit codes, so on Capture hosts every stopped container counts.",
|
||||||
|
"option": {
|
||||||
|
"onStopped": {
|
||||||
|
"label": "Alert when a container is stopped"
|
||||||
|
},
|
||||||
|
"onUnhealthy": {
|
||||||
|
"label": "Alert when a container is unhealthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"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.",
|
||||||
@@ -973,6 +848,29 @@
|
|||||||
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1126,6 +1024,10 @@
|
|||||||
"title": "Log collection is off for this host",
|
"title": "Log collection is off for this host",
|
||||||
"description": "Turn on container logs in the monitor settings to start collecting.",
|
"description": "Turn on container logs in the monitor settings to start collecting.",
|
||||||
"action": "Configure monitor"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1388,7 +1290,7 @@
|
|||||||
},
|
},
|
||||||
"startTime": {
|
"startTime": {
|
||||||
"title": "Start time",
|
"title": "Start time",
|
||||||
"description": "Set the start time and duration for your maintenance window. All values are in UTC",
|
"description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
|
||||||
"option": {
|
"option": {
|
||||||
"duration": {
|
"duration": {
|
||||||
"label": "Duration"
|
"label": "Duration"
|
||||||
@@ -1470,6 +1372,7 @@
|
|||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"pushover": "Pushover",
|
"pushover": "Pushover",
|
||||||
|
"signalgrid": "Signalgrid",
|
||||||
"twilio": "Twilio",
|
"twilio": "Twilio",
|
||||||
"ntfy": "ntfy"
|
"ntfy": "ntfy"
|
||||||
}
|
}
|
||||||
@@ -1490,6 +1393,14 @@
|
|||||||
"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.",
|
||||||
@@ -1786,7 +1697,7 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"up": "Operational",
|
"up": "Operational",
|
||||||
"down": "Down",
|
"down": "Down",
|
||||||
"breached": "Threshold exceeded",
|
"breached": "Alert",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"initializing": "Initializing"
|
"initializing": "Initializing"
|
||||||
@@ -1813,19 +1724,26 @@
|
|||||||
"uptime": {
|
"uptime": {
|
||||||
"title": "Uptime",
|
"title": "Uptime",
|
||||||
"responseTime": "Response time"
|
"responseTime": "Response time"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"modalTitle": "Incidents for {{date}}",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noIncidents": "No incidents reported for this day.",
|
||||||
|
"ongoing": "Ongoing",
|
||||||
|
"errorLoading": "Failed to load incidents."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
|
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
|
||||||
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
|
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
|
||||||
"allUp": "All systems operational",
|
"allUp": "All systems operational",
|
||||||
"allBreached": "All systems have exceeded thresholds",
|
"allBreached": "All systems have active alerts",
|
||||||
"allMaintenance": "All systems are under maintenance",
|
"allMaintenance": "All systems are under maintenance",
|
||||||
"allDown": "All systems are down",
|
"allDown": "All systems are down",
|
||||||
"allPaused": "All systems are paused",
|
"allPaused": "All systems are paused",
|
||||||
"allInitializing": "All systems are initializing",
|
"allInitializing": "All systems are initializing",
|
||||||
"breachedAndDown": "Some systems have exceeded thresholds and are down",
|
"breachedAndDown": "Some systems have active alerts and some are down",
|
||||||
"breached": "Some systems have exceeded thresholds",
|
"breached": "Some systems have active alerts",
|
||||||
"degraded": "Some systems are experiencing issues",
|
"degraded": "Some systems are experiencing issues",
|
||||||
"maintenanceAndDown": "Some systems are down during maintenance",
|
"maintenanceAndDown": "Some systems are down during maintenance",
|
||||||
"maintenance": "Some systems are under maintenance",
|
"maintenance": "Some systems are under maintenance",
|
||||||
@@ -2010,6 +1928,37 @@
|
|||||||
"title": "Uptime monitors",
|
"title": "Uptime monitors",
|
||||||
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
|
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"noData": "No data available at this moment.",
|
||||||
|
"monitorStatus": {
|
||||||
|
"ofUp": "of {{total}} up"
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"monitorStatus": "Monitor status",
|
||||||
|
"currentlyDown": "Currently down",
|
||||||
|
"uptime": "Monitor uptime",
|
||||||
|
"monitorsByType": "Monitors by type"
|
||||||
|
},
|
||||||
|
"uptimeCard": {
|
||||||
|
"toggleSortOrder": "Toggle sort order"
|
||||||
|
},
|
||||||
|
"currentlyDown": {
|
||||||
|
"empty": "No monitors are down at this moment."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"description": "See every monitor type in one place and spot what needs attention."
|
||||||
|
},
|
||||||
|
"editCards": "Edit cards",
|
||||||
|
"editCardsModal": {
|
||||||
|
"title": "Dashboard cards",
|
||||||
|
"description": "Select the cards to display on your dashboard.",
|
||||||
|
"groups": {
|
||||||
|
"uptime": "Uptime"
|
||||||
|
},
|
||||||
|
"done": "Done"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-141
@@ -182,7 +182,6 @@
|
|||||||
"maintenance": "Mantenimiento",
|
"maintenance": "Mantenimiento",
|
||||||
"logs": "Registros",
|
"logs": "Registros",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"dashboard": "Panel",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "Monitorización",
|
"monitoring": "Monitorización",
|
||||||
"alerting": "Alertas",
|
"alerting": "Alertas",
|
||||||
@@ -230,146 +229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "Panel",
|
|
||||||
"description": "Vea cada tipo de monitor en un solo lugar y detecte lo que necesita atención."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "No se pudo cargar esta tarjeta."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "Estado de los monitores",
|
|
||||||
"empty": "Aún no hay monitores.",
|
|
||||||
"upOfTotal": "de {{total}} activos"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "Caídos ahora",
|
|
||||||
"empty": "Todos los monitores están activos. Sin incidentes abiertos.",
|
|
||||||
"count": "{{count}} requieren atención"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "Monitores por tipo",
|
|
||||||
"empty": "Aún no hay monitores.",
|
|
||||||
"down": "{{count}} caídos"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "Incidentes recientes",
|
|
||||||
"empty": "Sin incidentes: todos los sistemas están sanos.",
|
|
||||||
"active": "{{count}} abiertos",
|
|
||||||
"ongoing": "En curso",
|
|
||||||
"manual": "Manual",
|
|
||||||
"unknownMonitor": "Monitor desconocido"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "Comprobaciones fallidas",
|
|
||||||
"empty": "Aún no hay comprobaciones.",
|
|
||||||
"ratio": "{{failed}} de {{total}} en el último día"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "Servidor de Checkmate",
|
|
||||||
"memory": "Memoria",
|
|
||||||
"eventLoop": "Retardo del bucle de eventos",
|
|
||||||
"uptime": "Tiempo activo",
|
|
||||||
"database": "Base de datos",
|
|
||||||
"readsWrites": "{{reads}} lecturas/s · {{writes}} escrituras/s"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "Top {{shown}} de {{total}}",
|
|
||||||
"notEnough": "No hay suficientes monitores para clasificar."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "Monitores más lentos"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "Menor uptime"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "Monitores por grupo",
|
|
||||||
"empty": "Aún no hay grupos. Los grupos se definen por monitor."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "Servidores más cargados",
|
|
||||||
"empty": "Aún no hay monitores de infraestructura.",
|
|
||||||
"cpu": "CPU",
|
|
||||||
"memory": "Memoria",
|
|
||||||
"disk": "Disco"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "Estadísticas de incidentes",
|
|
||||||
"openNow": "Abiertos ahora",
|
|
||||||
"avgResolution": "Tiempo medio de resolución",
|
|
||||||
"hours": "{{hours}} h",
|
|
||||||
"mostIncidents": "Más incidentes",
|
|
||||||
"total": "total",
|
|
||||||
"resolved": "Resueltos",
|
|
||||||
"automatic": "Automática",
|
|
||||||
"manual": "Manual",
|
|
||||||
"autoManual": "{{automatic}} automáticas · {{manual}} manuales"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "Canales de notificación",
|
|
||||||
"empty": "Aún no hay canales."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "Ventanas de mantenimiento",
|
|
||||||
"empty": "Sin mantenimiento programado.",
|
|
||||||
"runningNow": "En ejecución",
|
|
||||||
"next": "Próxima ventana"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "Páginas de estado",
|
|
||||||
"empty": "Aún no hay páginas de estado.",
|
|
||||||
"monitorCount": "{{count}} monitores",
|
|
||||||
"published": "Publicada",
|
|
||||||
"draft": "Borrador"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "Cola de comprobaciones",
|
|
||||||
"healthy": "Los {{jobs}} trabajos van según lo previsto en {{workers}} worker(s).",
|
|
||||||
"failing": "Trabajos fallidos",
|
|
||||||
"scheduled": "Trabajos programados",
|
|
||||||
"runningNow": "En ejecución"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "Errores recientes",
|
|
||||||
"empty": "No se registraron errores.",
|
|
||||||
"count": "{{count}} errores",
|
|
||||||
"warnings": "Advertencias"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "Miembros del equipo",
|
|
||||||
"members": "{{count}} miembros",
|
|
||||||
"unverified": "Sin verificar"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "Editar tarjetas",
|
|
||||||
"title": "Tarjetas del panel",
|
|
||||||
"done": "Listo",
|
|
||||||
"reset": "Restablecer valores predeterminados",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "Uptime",
|
|
||||||
"infrastructure": "Infraestructura",
|
|
||||||
"incidents": "Incidentes",
|
|
||||||
"notifications": "Notificaciones",
|
|
||||||
"maintenance": "Mantenimiento",
|
|
||||||
"statusPages": "Páginas de estado",
|
|
||||||
"checks": "Comprobaciones",
|
|
||||||
"logs": "Sistema",
|
|
||||||
"settings": "Configuración"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "No hay tarjetas en tu panel",
|
|
||||||
"description": "Elige las tarjetas que quieres ver. Tu selección se recuerda en este navegador."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "Mover arriba",
|
|
||||||
"moveDown": "Mover abajo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "¡Oh no! ¡Se le cayó el sushi!",
|
"title": "¡Oh no! ¡Se le cayó el sushi!",
|
||||||
"subtitle": "La URL no existe o no tiene acceso a ella."
|
"subtitle": "La URL no existe o no tiene acceso a ella."
|
||||||
@@ -1518,6 +1377,9 @@
|
|||||||
"maintenance": "Mantenimiento",
|
"maintenance": "Mantenimiento",
|
||||||
"paused": "Pausado",
|
"paused": "Pausado",
|
||||||
"up": "Operativo"
|
"up": "Operativo"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Error al cargar incidentes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
+3
-141
@@ -182,7 +182,6 @@
|
|||||||
"maintenance": "Huolto",
|
"maintenance": "Huolto",
|
||||||
"logs": "Lokit",
|
"logs": "Lokit",
|
||||||
"settings": "Asetukset",
|
"settings": "Asetukset",
|
||||||
"dashboard": "Kojelauta",
|
|
||||||
"groups": {
|
"groups": {
|
||||||
"monitoring": "Valvonta",
|
"monitoring": "Valvonta",
|
||||||
"alerting": "Hälytykset",
|
"alerting": "Hälytykset",
|
||||||
@@ -230,146 +229,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"dashboard": {
|
|
||||||
"header": {
|
|
||||||
"title": "Kojelauta",
|
|
||||||
"description": "Näe kaikki monitorityypit yhdessä paikassa ja huomaa huomiota vaativat asiat."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"error": "Tätä korttia ei voitu ladata."
|
|
||||||
},
|
|
||||||
"cards": {
|
|
||||||
"monitorStatus": {
|
|
||||||
"title": "Monitorien tila",
|
|
||||||
"empty": "Ei vielä monitoreja.",
|
|
||||||
"upOfTotal": "/ {{total}} käynnissä"
|
|
||||||
},
|
|
||||||
"currentlyDown": {
|
|
||||||
"title": "Tällä hetkellä alhaalla",
|
|
||||||
"empty": "Kaikki monitorit ovat käynnissä. Ei avoimia tapauksia.",
|
|
||||||
"count": "{{count}} vaatii huomiota"
|
|
||||||
},
|
|
||||||
"monitorsByType": {
|
|
||||||
"title": "Monitorit tyypeittäin",
|
|
||||||
"empty": "Ei vielä monitoreja.",
|
|
||||||
"down": "{{count}} alhaalla"
|
|
||||||
},
|
|
||||||
"recentIncidents": {
|
|
||||||
"title": "Viimeisimmät tapaukset",
|
|
||||||
"empty": "Ei tapauksia — kaikki järjestelmät kunnossa.",
|
|
||||||
"active": "{{count}} avoinna",
|
|
||||||
"ongoing": "Käynnissä",
|
|
||||||
"manual": "Manuaalinen",
|
|
||||||
"unknownMonitor": "Tuntematon monitori"
|
|
||||||
},
|
|
||||||
"failedChecks": {
|
|
||||||
"title": "Epäonnistuneet tarkistukset",
|
|
||||||
"empty": "Ei vielä tarkistuksia.",
|
|
||||||
"ratio": "{{failed}} / {{total}} viimeisen vuorokauden aikana"
|
|
||||||
},
|
|
||||||
"checkmateServer": {
|
|
||||||
"title": "Checkmate-palvelin",
|
|
||||||
"memory": "Muisti",
|
|
||||||
"eventLoop": "Tapahtumasilmukan viive",
|
|
||||||
"uptime": "Käyntiaika",
|
|
||||||
"database": "Tietokanta",
|
|
||||||
"readsWrites": "{{reads}} lukua/s · {{writes}} kirjoitusta/s"
|
|
||||||
},
|
|
||||||
"ranking": {
|
|
||||||
"topOf": "Top {{shown}} / {{total}}",
|
|
||||||
"notEnough": "Ei tarpeeksi monitoreja järjestykseen."
|
|
||||||
},
|
|
||||||
"slowestMonitors": {
|
|
||||||
"title": "Hitaimmat monitorit"
|
|
||||||
},
|
|
||||||
"lowestUptime": {
|
|
||||||
"title": "Alhaisin käytettävyys"
|
|
||||||
},
|
|
||||||
"monitorsByGroup": {
|
|
||||||
"title": "Monitorit ryhmittäin",
|
|
||||||
"empty": "Ei vielä ryhmiä. Ryhmät asetetaan monitorikohtaisesti."
|
|
||||||
},
|
|
||||||
"busiestServers": {
|
|
||||||
"title": "Kuormitetuimmat palvelimet",
|
|
||||||
"empty": "Ei vielä infrastruktuurimonitoreja.",
|
|
||||||
"cpu": "Suoritin",
|
|
||||||
"memory": "Muisti",
|
|
||||||
"disk": "Levy"
|
|
||||||
},
|
|
||||||
"incidentStats": {
|
|
||||||
"title": "Tapaustilastot",
|
|
||||||
"openNow": "Avoinna nyt",
|
|
||||||
"avgResolution": "Keskimääräinen ratkaisuaika",
|
|
||||||
"hours": "{{hours}} t",
|
|
||||||
"mostIncidents": "Eniten tapauksia",
|
|
||||||
"total": "yhteensä",
|
|
||||||
"resolved": "Ratkaistu",
|
|
||||||
"automatic": "Automaattinen",
|
|
||||||
"manual": "Manuaalinen",
|
|
||||||
"autoManual": "{{automatic}} automaattista · {{manual}} manuaalista"
|
|
||||||
},
|
|
||||||
"notificationChannels": {
|
|
||||||
"title": "Ilmoituskanavat",
|
|
||||||
"empty": "Ei vielä kanavia."
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"title": "Huoltoikkunat",
|
|
||||||
"empty": "Ei suunniteltua huoltoa.",
|
|
||||||
"runningNow": "Käynnissä nyt",
|
|
||||||
"next": "Seuraava ikkuna"
|
|
||||||
},
|
|
||||||
"statusPages": {
|
|
||||||
"title": "Tilasivut",
|
|
||||||
"empty": "Ei vielä tilasivuja.",
|
|
||||||
"monitorCount": "{{count}} monitoria",
|
|
||||||
"published": "Julkaistu",
|
|
||||||
"draft": "Luonnos"
|
|
||||||
},
|
|
||||||
"checksOnSchedule": {
|
|
||||||
"title": "Tarkistusjono",
|
|
||||||
"healthy": "Kaikki {{jobs}} työtä aikataulussa {{workers}} työntekijällä.",
|
|
||||||
"failing": "Epäonnistuvat työt",
|
|
||||||
"scheduled": "Ajastetut työt",
|
|
||||||
"runningNow": "Käynnissä nyt"
|
|
||||||
},
|
|
||||||
"recentErrors": {
|
|
||||||
"title": "Viimeisimmät virheet",
|
|
||||||
"empty": "Ei kirjattuja virheitä.",
|
|
||||||
"count": "{{count}} virhettä",
|
|
||||||
"warnings": "Varoitukset"
|
|
||||||
},
|
|
||||||
"team": {
|
|
||||||
"title": "Tiimin jäsenet",
|
|
||||||
"members": "{{count}} jäsentä",
|
|
||||||
"unverified": "Vahvistamattomat"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"picker": {
|
|
||||||
"trigger": "Muokkaa kortteja",
|
|
||||||
"title": "Kojelaudan kortit",
|
|
||||||
"done": "Valmis",
|
|
||||||
"reset": "Palauta oletukset",
|
|
||||||
"groups": {
|
|
||||||
"uptime": "Käytettävyys",
|
|
||||||
"infrastructure": "Infrastruktuuri",
|
|
||||||
"incidents": "Tapaukset",
|
|
||||||
"notifications": "Ilmoitukset",
|
|
||||||
"maintenance": "Huolto",
|
|
||||||
"statusPages": "Tilasivut",
|
|
||||||
"checks": "Tarkistukset",
|
|
||||||
"logs": "Järjestelmä",
|
|
||||||
"settings": "Asetukset"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "Kojelaudallasi ei ole kortteja",
|
|
||||||
"description": "Valitse näytettävät kortit. Valintasi muistetaan tässä selaimessa."
|
|
||||||
},
|
|
||||||
"reorder": {
|
|
||||||
"moveUp": "Siirrä ylös",
|
|
||||||
"moveDown": "Siirrä alas"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Voi ei! Sushisi putosi!",
|
"title": "Voi ei! Sushisi putosi!",
|
||||||
"subtitle": "Joko URL-osoitetta ei ole olemassa tai sinulla ei ole pääsyä siihen."
|
"subtitle": "Joko URL-osoitetta ei ole olemassa tai sinulla ei ole pääsyä siihen."
|
||||||
@@ -1518,6 +1377,9 @@
|
|||||||
"maintenance": "Huolto",
|
"maintenance": "Huolto",
|
||||||
"paused": "Keskeytetty",
|
"paused": "Keskeytetty",
|
||||||
"up": "Toiminnassa"
|
"up": "Toiminnassa"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Häiriöiden lataaminen epäonnistui."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user