Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ad82d1e02 | ||
|
|
f41e0f0619 | ||
|
|
3f10b6e567 | ||
|
|
9ad20daa51 | ||
|
|
6a9068a151 | ||
|
|
b31ffa5318 | ||
|
|
102a9f1484 | ||
|
|
581ec9382b | ||
|
|
cebe9350f7 | ||
|
|
406518e0a5 | ||
|
|
c458d5fb65 | ||
|
|
ba0d685c6d | ||
|
|
912f1f960d | ||
|
|
1de0edf7e3 | ||
|
|
8c9cb4be77 | ||
|
|
727b904ca9 | ||
|
|
5c0279a6a7 | ||
|
|
9d72d20dd2 | ||
|
|
62d0630702 | ||
|
|
2260b4a4d2 | ||
|
|
cfb50afe42 | ||
|
|
b14b2142e3 | ||
|
|
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 | ||
|
|
26c0628f34 | ||
|
|
4bbc84ac48 | ||
|
|
2eeb088b61 | ||
|
|
7862b3c6f9 | ||
|
|
02b0daa37d | ||
|
|
1492569b73 | ||
|
|
a545b81569 | ||
|
|
ce9dce48b3 | ||
|
|
7fcdcc0568 | ||
|
|
9eea53aa5f | ||
|
|
bd7b6038f5 | ||
|
|
e2362ca809 | ||
|
|
9a541dac21 | ||
|
|
71fed33ecd | ||
|
|
074b94b061 | ||
|
|
c1af8995e4 | ||
|
|
431cada08e | ||
|
|
e012910332 | ||
|
|
7dbc7b3064 | ||
|
|
1b97539eae | ||
|
|
6e0afe223e | ||
|
|
37e1005479 | ||
|
|
795e1caca4 | ||
|
|
a5bb186814 | ||
|
|
bee3d4221e | ||
|
|
f7e1793bac | ||
|
|
9a8203b0d8 | ||
|
|
24d4595bb1 | ||
|
|
6587ddf389 | ||
|
|
9b939f2d94 | ||
|
|
530eeb0110 | ||
|
|
da0d2a47b4 | ||
|
|
700baf2061 | ||
|
|
9c796edbde | ||
|
|
84a1b994a1 | ||
|
|
db4689207a | ||
|
|
af465766c8 | ||
|
|
6ea4ca1aa9 | ||
|
|
3e06cd39b1 | ||
|
|
efcc7829d4 | ||
|
|
77974682fb | ||
|
|
c13f26a4c3 | ||
|
|
54eaef725a | ||
|
|
2a2226456c | ||
|
|
b8d4e4fc59 | ||
|
|
24cf1ec6c8 | ||
|
|
a76f837803 | ||
|
|
9da2653d7b | ||
|
|
0092c9c006 | ||
|
|
348502834c | ||
|
|
1e007551ef | ||
|
|
07c62970b5 | ||
|
|
f0123d8052 | ||
|
|
de7d5b38ef | ||
|
|
e51a55b14d | ||
|
|
1a5e8dc77d | ||
|
|
3b9e162315 | ||
|
|
89dec1c82b | ||
|
|
1a44080b84 | ||
|
|
762615b258 | ||
|
|
f9036f0863 | ||
|
|
990f353040 | ||
|
|
37836e56b8 | ||
|
|
5e4060cfe3 | ||
|
|
c90bef4cf8 | ||
|
|
61bcae9aff | ||
|
|
aa8dec6ca1 | ||
|
|
e9bd8150fa | ||
|
|
ce2808ccbc | ||
|
|
58692f8b72 | ||
|
|
421f1635ef | ||
|
|
b9f24f290d | ||
|
|
a84cb95c50 | ||
|
|
ac6e4ded73 | ||
|
|
5a6d59576a | ||
|
|
3a34ee89d4 | ||
|
|
ba2218490b | ||
|
|
188966db49 | ||
|
|
35b10b0a23 | ||
|
|
082d44cac7 | ||
|
|
a88f5def87 | ||
|
|
033a8e6ca1 | ||
|
|
bd7016483d | ||
|
|
6bb885c98b | ||
|
|
2ca441548b | ||
|
|
53461bbb59 | ||
|
|
82ad0bee9d | ||
|
|
250793ccdd | ||
|
|
93b3cc8b68 | ||
|
|
19f7c30876 | ||
|
|
c3cfdb68a5 | ||
|
|
bad7a3c7ea | ||
|
|
4ef0ef53ed | ||
|
|
07da6ce4ce | ||
|
|
48b604bef9 | ||
|
|
f14cd68fcc | ||
|
|
b06aa07b3d | ||
|
|
1ba63985dd | ||
|
|
8968d01781 | ||
|
|
daa63e7c54 | ||
|
|
305586d55b | ||
|
|
881fbdeeb4 | ||
|
|
dd109c0e5c |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
|
||||
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
<img width="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
|
||||
</p>
|
||||
<br>
|
||||
|
||||
@@ -93,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.
|
||||
|
||||
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), [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
|
||||
@@ -105,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` |
|
||||
| `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 |
|
||||
| `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`) |
|
||||
| `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:
|
||||
|
||||
@@ -119,12 +126,119 @@ 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).
|
||||
|
||||
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
|
||||
|
||||
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
||||
|
||||
### Docker monitors
|
||||
|
||||
A Docker monitor connects to a Docker daemon and reports on every container it runs. The daemon's ping response decides whether the monitor is up or down and its latency is the response time. Each check also records every container's state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every check; logs are kept for 7 days.
|
||||
|
||||
The **Docker host** field accepts two forms:
|
||||
|
||||
| Host | Example | Notes |
|
||||
|---|---|---|
|
||||
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
|
||||
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
|
||||
|
||||
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
checkmate:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
group_add:
|
||||
- "989" # the gid printed by stat
|
||||
```
|
||||
|
||||
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
|
||||
|
||||
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
|
||||
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
|
||||
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
|
||||
|
||||
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
|
||||
|
||||
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
|
||||
|
||||
### Securing the Docker daemon with TLS
|
||||
|
||||
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
|
||||
|
||||
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
|
||||
|
||||
```bash
|
||||
openssl genrsa -aes256 -out ca-key.pem 4096
|
||||
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
|
||||
```
|
||||
|
||||
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
|
||||
|
||||
```bash
|
||||
openssl genrsa -out server-key.pem 4096
|
||||
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
|
||||
cat > server-ext.cnf <<EOF
|
||||
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
|
||||
extendedKeyUsage = serverAuth
|
||||
EOF
|
||||
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||
-out server-cert.pem -extfile server-ext.cnf
|
||||
```
|
||||
|
||||
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
|
||||
|
||||
```bash
|
||||
openssl genrsa -out key.pem 4096
|
||||
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
|
||||
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
|
||||
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||
-out cert.pem -extfile client-ext.cnf
|
||||
rm client.csr server.csr server-ext.cnf client-ext.cnf
|
||||
chmod 0400 ca-key.pem key.pem server-key.pem
|
||||
chmod 0444 ca.pem server-cert.pem cert.pem
|
||||
```
|
||||
|
||||
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
|
||||
"tls": true,
|
||||
"tlsverify": true,
|
||||
"tlscacert": "/etc/docker/certs/ca.pem",
|
||||
"tlscert": "/etc/docker/certs/server-cert.pem",
|
||||
"tlskey": "/etc/docker/certs/server-key.pem"
|
||||
}
|
||||
```
|
||||
|
||||
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit docker.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/dockerd
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload && sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Open port `2376` on the host firewall only to the machine running Checkmate.
|
||||
|
||||
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
|
||||
|
||||
```bash
|
||||
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
|
||||
-H=docker.example.com:2376 version
|
||||
```
|
||||
|
||||
For more documentation, see the [docs directory](./docs/).
|
||||
|
||||
<a id="performance"></a>
|
||||
@@ -134,7 +248,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:
|
||||
|
||||

|
||||
|
||||
@@ -152,16 +266,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
## Features
|
||||
|
||||
- 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
|
||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||
- Selective disk monitoring with mountpoint selection
|
||||
- Incidents at a glance
|
||||
- Status pages with 4 beautiful themes
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||
- Status pages with 5 beautiful themes
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
|
||||
- Scheduled maintenance
|
||||
- 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
|
||||
@@ -212,9 +326,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
<a id="contributing"></a>
|
||||
## Contributing
|
||||
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [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!
|
||||
|
||||
|
||||
Generated
+24
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "3.11.0",
|
||||
"version": "3.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client",
|
||||
"version": "3.11.0",
|
||||
"version": "3.12.0",
|
||||
"dependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
@@ -93,6 +93,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -408,6 +409,7 @@
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -445,6 +447,7 @@
|
||||
"node_modules/@emotion/styled": {
|
||||
"version": "11.14.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1233,6 +1236,7 @@
|
||||
"node_modules/@mui/material": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.7",
|
||||
@@ -1337,6 +1341,7 @@
|
||||
"node_modules/@mui/system": {
|
||||
"version": "7.3.7",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/private-theming": "^7.3.7",
|
||||
@@ -2381,6 +2386,7 @@
|
||||
"version": "24.5.2",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.12.0"
|
||||
}
|
||||
@@ -2396,6 +2402,7 @@
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2486,6 +2493,7 @@
|
||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.18.0",
|
||||
"@typescript-eslint/types": "7.18.0",
|
||||
@@ -2766,6 +2774,7 @@
|
||||
"version": "8.15.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3122,6 +3131,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
@@ -3565,7 +3575,8 @@
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
@@ -3964,6 +3975,7 @@
|
||||
"version": "8.57.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4776,6 +4788,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -5587,6 +5600,7 @@
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@mapbox/geojson-rewind": "^0.5.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
@@ -6368,6 +6382,7 @@
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6379,6 +6394,7 @@
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.71.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -6582,7 +6598,8 @@
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-persist": {
|
||||
"version": "6.0.0",
|
||||
@@ -6732,6 +6749,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -7573,6 +7591,7 @@
|
||||
"version": "5.9.3",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7710,6 +7729,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "3.11.0",
|
||||
"version": "3.12.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
@@ -12,7 +12,8 @@
|
||||
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write .",
|
||||
"format-check": "prettier --check ."
|
||||
"format-check": "prettier --check .",
|
||||
"compile": "tsc -b"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Box, { type BoxProps } from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
@@ -16,4 +18,6 @@ export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
BaseBox.displayName = "BaseBox";
|
||||
@@ -8,9 +8,6 @@ interface IconProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// 16 is the house size: 17 call sites ask for it explicitly against 2 for 20,
|
||||
// and the sidebar clamps to 16 in CSS. A 20px default put icons above the cap
|
||||
// height of the 13px text they sit beside.
|
||||
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
|
||||
@@ -37,12 +37,6 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
// `error.dark` rather than `main`: in dark mode `main` is light
|
||||
// enough that MUI derives near-black contrastText, which reads as
|
||||
// a warning rather than an outage on the one surface where
|
||||
// urgency matters. `dark` keeps a solid red bar with white text
|
||||
// in both modes, and leaves `main` alone for the ~30 places that
|
||||
// use it as text or a chart mark on a dark ground.
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
color: "#FFFFFF",
|
||||
px: theme.spacing(8),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -110,9 +110,6 @@ export const PageHeader = ({
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: 1.55,
|
||||
// Wide enough to hold a one-line page description at this
|
||||
// size; still a cap, so it wraps rather than running the
|
||||
// full width of a large monitor.
|
||||
maxWidth: "108ch",
|
||||
}}
|
||||
>
|
||||
@@ -146,7 +143,7 @@ export const PageHeader = ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
color: theme.palette.text.secondary,
|
||||
borderRight:
|
||||
idx < linkItems.length - 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
width?: number | string | { xs?: number | string; md?: number | string };
|
||||
@@ -45,7 +45,7 @@ export const SkeletonCard = ({
|
||||
|
||||
const blockStyle = {
|
||||
width: 55,
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
@@ -19,15 +19,8 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
// In light mode `paper` is white, and lightening white is a no-op - the
|
||||
// gradient collapses flat. Darken slightly towards the page ground instead.
|
||||
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
||||
const paperEnd = paper;
|
||||
// Both ends stay close to `main` so MUI's derived `contrastText` still
|
||||
// applies - that is white on the dark light-mode fills and near-black on
|
||||
// the lighter dark-mode ones, so a bigger lift would break one or other.
|
||||
// `light` is a mid-tone used for status dots and chart marks, too pale to
|
||||
// use here, so the lift comes off `main` instead.
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
@@ -33,12 +33,6 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
||||
bottom={0}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
// Deliberately far fainter than `divider` - this is texture
|
||||
// behind the number, not a rule, and it must never compete
|
||||
// with the box's own border. Note alpha() *replaces* the
|
||||
// alpha channel, so this relies on `divider` being an opaque
|
||||
// hex; if it ever goes translucent this gets stronger, not
|
||||
// weaker.
|
||||
backgroundImage: `
|
||||
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
||||
|
||||
@@ -52,11 +52,6 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
||||
emptyViewText?: string;
|
||||
emptyViewPositive?: boolean;
|
||||
getRowSx?: (row: T) => SxProps<Theme>;
|
||||
/**
|
||||
* Marks a row selected so it gets the shared selected + hover treatment.
|
||||
* Desktop table only - the small-screen card branch does not style
|
||||
* selection yet, same as `getRowSx`.
|
||||
*/
|
||||
isRowSelected?: (row: T) => boolean;
|
||||
};
|
||||
|
||||
@@ -86,6 +81,21 @@ export function DataTable<
|
||||
};
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const hasTextSelectionWithin = (element: Element) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
selection.anchorNode !== null &&
|
||||
element.contains(
|
||||
selection.anchorNode.nodeType === Node.TEXT_NODE
|
||||
? selection.anchorNode.parentNode
|
||||
: selection.anchorNode
|
||||
)
|
||||
);
|
||||
};
|
||||
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||
|
||||
if (data.length === 0 || headers.length === 0) {
|
||||
@@ -107,7 +117,10 @@ export function DataTable<
|
||||
keys.push(key);
|
||||
return (
|
||||
<Stack
|
||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
||||
onClick={(e) => {
|
||||
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||
if (onRowClick) onRowClick(row);
|
||||
}}
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
sx={{
|
||||
borderStyle: "solid",
|
||||
@@ -220,21 +233,6 @@ export function DataTable<
|
||||
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
// Cells paint `background.paper`, so a background set on the row
|
||||
// never shows. Selection and hover both have to be applied at
|
||||
// cell level, and selection has to come first so hover layers
|
||||
// on top of it rather than replacing it.
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-selected .MuiTableCell-root": {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
|
||||
{
|
||||
backgroundColor: theme.palette.action.rowHover,
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root.is-selected.is-clickable:hover .MuiTableCell-root":
|
||||
{
|
||||
backgroundColor: theme.palette.action.selectedHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
@@ -255,21 +253,29 @@ export function DataTable<
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
const isExpanded = expanded === key;
|
||||
const selected = isRowSelected?.(row);
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<TableRow
|
||||
className={[
|
||||
isInteractive ? "is-clickable" : "",
|
||||
isRowSelected?.(row) ? "is-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
sx={{
|
||||
cursor: isInteractive ? "pointer" : "default",
|
||||
...(isInteractive && {
|
||||
"&:hover .MuiTableCell-root": {
|
||||
backgroundColor: selected
|
||||
? theme.palette.action.selectedHover
|
||||
: theme.palette.action.rowHover,
|
||||
},
|
||||
}),
|
||||
...(selected && {
|
||||
"& .MuiTableCell-root": {
|
||||
backgroundColor: theme.palette.action.selected,
|
||||
},
|
||||
}),
|
||||
...(getRowSx?.(row) as object),
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||
if (expandableRows) handleExpand(row);
|
||||
else if (onRowClick) onRowClick(row);
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import MuiTabs from "@mui/material/Tabs";
|
||||
import type { TabsProps } from "@mui/material/Tabs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
interface CustomTabsProps extends TabsProps {}
|
||||
|
||||
export const Tabs = (props: CustomTabsProps) => {
|
||||
@@ -10,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
||||
return (
|
||||
<MuiTabs
|
||||
sx={{
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
"& .MuiTabs-indicator": {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
@@ -42,17 +42,13 @@ export const Tab = (props: CustomTabProps) => {
|
||||
textTransform: "none",
|
||||
fontSize: typographyLevels.m,
|
||||
fontWeight: 500,
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
padding: theme.spacing(1, 0),
|
||||
paddingBottom: 0,
|
||||
minWidth: "auto",
|
||||
alignItems: "flex-start",
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-selected": {
|
||||
// Weight stays put, as on ToggleButton: bolding the active tab
|
||||
// widens it, shifting every tab to its right and dragging the
|
||||
// indicator to a moving target. Colour and the indicator are
|
||||
// already carrying the state.
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"&:hover": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme, darken } from "@mui/material/styles";
|
||||
import { CONTROL_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||
|
||||
const PALETTE_COLORS = [
|
||||
"primary",
|
||||
@@ -43,10 +43,7 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
{...props}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
height: CONTROL_HEIGHT,
|
||||
// MUI's button line-height is 1.75 (22.75px at 13px), which with the
|
||||
// 6px padding needs 34.75px - already over its own box at 34, and
|
||||
// clipping at 32. 1.2 leaves room for tall scripts too.
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 400,
|
||||
borderRadius: 2,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface ColorPickerProps extends MuiColorInputProps {
|
||||
fieldLabel?: string;
|
||||
@@ -19,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FieldLabel } from "./FieldLabel";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -56,7 +56,7 @@ export const DatePickerComponent = ({
|
||||
flexDirection: "row-reverse",
|
||||
},
|
||||
"& input": {
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
p: 0,
|
||||
pl: theme.spacing(3),
|
||||
pr: theme.spacing(5),
|
||||
|
||||
@@ -6,7 +6,7 @@ import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -72,7 +72,7 @@ const SelectInputInner = <T,>(
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
height: `${CONTROL_HEIGHT}px`,
|
||||
height: `${INPUT_BASE_HEIGHT}px`,
|
||||
"& .MuiSelect-select": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { CONTROL_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: props.multiline ? "auto" : CONTROL_HEIGHT,
|
||||
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
|
||||
fontSize: typographyLevels.base,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||
fieldLabel?: string;
|
||||
@@ -34,7 +34,7 @@ export const TimePickerComponent = ({
|
||||
sx: {
|
||||
width: "fit-content",
|
||||
"& input": {
|
||||
minHeight: CONTROL_HEIGHT,
|
||||
minHeight: INPUT_BASE_HEIGHT,
|
||||
p: 0,
|
||||
px: theme.spacing(5),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { CONTROL_HEIGHT } from "@/Utils/Theme/constants";
|
||||
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||
|
||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
const theme = useTheme();
|
||||
@@ -21,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
sx={{
|
||||
px: 8,
|
||||
textTransform: "none",
|
||||
height: CONTROL_HEIGHT,
|
||||
height: INPUT_BASE_HEIGHT,
|
||||
fontWeight: 400,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
@@ -34,10 +34,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
},
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: selectedBg,
|
||||
// Weight stays at 400 so the group sits level with Button,
|
||||
// which it is routinely placed beside. The fill and the
|
||||
// darker ink carry the selected state instead - a weight
|
||||
// change also reflows the label width as you switch.
|
||||
color: theme.palette.text.primary,
|
||||
"&:hover": {
|
||||
backgroundColor: selectedHoverBg,
|
||||
|
||||
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
loading={isPosting}
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
onClick={async () => {
|
||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||
}}
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.testNotifications")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
loading={isPosting}
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
onClick={async () => {
|
||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||
}}
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.testNotifications")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MonitorListPage = ({
|
||||
headerKey,
|
||||
page,
|
||||
actionLink,
|
||||
controller: c,
|
||||
controller,
|
||||
bulkActions,
|
||||
showTypeFilter,
|
||||
summaryProps,
|
||||
@@ -48,14 +48,14 @@ export const MonitorListPage = ({
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isAdmin = useIsAdmin();
|
||||
const isLoading = c.isLoading || Boolean(extraLoading);
|
||||
const isLoading = controller.isLoading || Boolean(extraLoading);
|
||||
|
||||
return (
|
||||
<MonitorBasePageWithStates
|
||||
headerKey={headerKey}
|
||||
loading={isLoading}
|
||||
error={c.error || extraError}
|
||||
totalCount={c.effectiveTotalCount}
|
||||
error={controller.error || extraError}
|
||||
totalCount={controller.effectiveTotalCount}
|
||||
page={page}
|
||||
actionLink={actionLink}
|
||||
priorityFallback={priorityFallback}
|
||||
@@ -66,7 +66,7 @@ export const MonitorListPage = ({
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<HeaderMonitorsSummary
|
||||
summary={c.summary}
|
||||
summary={controller.summary}
|
||||
{...summaryProps}
|
||||
/>
|
||||
<Stack
|
||||
@@ -78,27 +78,27 @@ export const MonitorListPage = ({
|
||||
{...(showTypeFilter === false
|
||||
? { showTypes: false }
|
||||
: {
|
||||
selectedTypes: c.selectedTypes,
|
||||
setSelectedTypes: c.setSelectedTypes,
|
||||
selectedTypes: controller.selectedTypes,
|
||||
setSelectedTypes: controller.setSelectedTypes,
|
||||
})}
|
||||
selectedStatus={c.selectedStatus}
|
||||
setSelectedStatus={c.setSelectedStatus}
|
||||
selectedState={c.selectedState}
|
||||
setSelectedState={c.setSelectedState}
|
||||
tagOptions={c.tags ?? []}
|
||||
selectedTags={c.selectedTags}
|
||||
setSelectedTags={c.setSelectedTags}
|
||||
onClearFilters={c.handleClearFilters}
|
||||
selectedStatus={controller.selectedStatus}
|
||||
setSelectedStatus={controller.setSelectedStatus}
|
||||
selectedState={controller.selectedState}
|
||||
setSelectedState={controller.setSelectedState}
|
||||
tagOptions={controller.tags ?? []}
|
||||
selectedTags={controller.selectedTags}
|
||||
setSelectedTags={controller.setSelectedTags}
|
||||
onClearFilters={controller.handleClearFilters}
|
||||
/>
|
||||
<TextField
|
||||
placeholder={t("pages.uptime.filters.search.placeholder")}
|
||||
value={c.search}
|
||||
value={controller.search}
|
||||
onChange={(event) => {
|
||||
c.setSearch(event.target.value);
|
||||
controller.setSearch(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
{c.selectedTags.length > 0 && (
|
||||
{controller.selectedTags.length > 0 && (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
alignItems={isSmall ? "flex-start" : "center"}
|
||||
@@ -108,8 +108,8 @@ export const MonitorListPage = ({
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.uptime.filters.activeTags")}
|
||||
</Typography>
|
||||
{c.selectedTags.map((tagId) => {
|
||||
const tag = c.tags?.find((t) => t.id === tagId);
|
||||
{controller.selectedTags.map((tagId) => {
|
||||
const tag = controller.tags?.find((t) => t.id === tagId);
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<ColoredLabel
|
||||
@@ -123,20 +123,20 @@ export const MonitorListPage = ({
|
||||
)}
|
||||
{bulkActions && !isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={c.selectedRows.length}
|
||||
onCancel={c.handleCancelSelection}
|
||||
selectedCount={controller.selectedRows.length}
|
||||
onCancel={controller.handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={c.handleBulkResume}
|
||||
onClick={controller.handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={c.handleBulkPause}
|
||||
onClick={controller.handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
@@ -144,12 +144,12 @@ export const MonitorListPage = ({
|
||||
)}
|
||||
{children}
|
||||
<Dialog
|
||||
open={c.isDialogOpen}
|
||||
open={controller.isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={c.handleConfirmDelete}
|
||||
onCancel={c.handleCancelDelete}
|
||||
loading={c.isDeleting}
|
||||
onConfirm={controller.handleConfirmDelete}
|
||||
onCancel={controller.handleCancelDelete}
|
||||
loading={controller.isDeleting}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,58 @@
|
||||
import { BaseChart } from "@/Components/design-elements";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
|
||||
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||
import type { TooltipProps } from "recharts";
|
||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Fragment, useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
// Utils
|
||||
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||
dataKey: string;
|
||||
range: string;
|
||||
theme: Theme;
|
||||
uiTimezone: string;
|
||||
valueFormatter: (value: number) => string;
|
||||
labelKey: string;
|
||||
};
|
||||
|
||||
const DockerHistogramToolTip = ({
|
||||
dataKey,
|
||||
active,
|
||||
payload,
|
||||
range,
|
||||
theme,
|
||||
uiTimezone,
|
||||
valueFormatter,
|
||||
labelKey,
|
||||
}: DockerHistogramToolTipProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!payload) return null;
|
||||
if (!active) return null;
|
||||
const format = tooltipDateFormatLookup(range);
|
||||
const ts = payload[0].payload?._id ?? null;
|
||||
const dataPoint = payload[0].payload?.[dataKey] ?? null;
|
||||
|
||||
return (
|
||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
|
||||
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
interface HistogramDockerContainerProps {
|
||||
title: string;
|
||||
rightTitle: string;
|
||||
@@ -17,6 +60,9 @@ interface HistogramDockerContainerProps {
|
||||
dataKey: string;
|
||||
strokeColor: string;
|
||||
gradientStartColor: string;
|
||||
range: DateRange;
|
||||
formatter: (value: number) => string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export const HistogramDockerContainer = ({
|
||||
@@ -26,11 +72,14 @@ export const HistogramDockerContainer = ({
|
||||
dataKey,
|
||||
strokeColor,
|
||||
gradientStartColor,
|
||||
range,
|
||||
formatter,
|
||||
labelKey,
|
||||
}: HistogramDockerContainerProps) => {
|
||||
const uniqueId = useId();
|
||||
const theme = useTheme();
|
||||
const gradientId = `gradient-${uniqueId}`;
|
||||
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
return (
|
||||
<BaseChart
|
||||
icon="null"
|
||||
@@ -56,6 +105,19 @@ export const HistogramDockerContainer = ({
|
||||
endColor: "transparent",
|
||||
direction: "vertical",
|
||||
})}
|
||||
<Tooltip
|
||||
content={(props) => (
|
||||
<DockerHistogramToolTip
|
||||
{...props}
|
||||
range={range}
|
||||
theme={theme}
|
||||
uiTimezone={uiTimezone}
|
||||
dataKey={dataKey}
|
||||
valueFormatter={formatter}
|
||||
labelKey={labelKey}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
dataKey={dataKey}
|
||||
type="monotone"
|
||||
|
||||
@@ -32,9 +32,9 @@ export const getMenu = (t: Function) => {
|
||||
icon: <Icon icon={Globe} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.infrastructure"),
|
||||
@@ -42,9 +42,9 @@ export const getMenu = (t: Function) => {
|
||||
icon: <Icon icon={Link} />,
|
||||
},
|
||||
{
|
||||
name: t("components.sidebar.menu.docker"),
|
||||
path: "docker",
|
||||
icon: <Icon icon={Container} />,
|
||||
name: t("components.sidebar.menu.pagespeed"),
|
||||
path: "pagespeed",
|
||||
icon: <Icon icon={Gauge} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,10 +25,6 @@ export const NavItem = ({
|
||||
const iconStroke = selected
|
||||
? theme.palette.sidebar.accent
|
||||
: theme.palette.text.secondary;
|
||||
|
||||
// Both states come from the same family so selected always reads stronger
|
||||
// than a hovered neighbour. MUI's default action.hover is lighter than
|
||||
// action.selected on a dark ground, which inverted the two.
|
||||
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
|
||||
const buttonBgHoverColor = selected
|
||||
? theme.palette.action.selectedHover
|
||||
|
||||
@@ -73,9 +73,6 @@ export const Sidebar = () => {
|
||||
gap={theme.spacing(LAYOUT.SM)}
|
||||
borderRight={`1px solid ${theme.palette.divider}`}
|
||||
width={width}
|
||||
// On `paper` rather than inheriting the page ground, so the nav
|
||||
// reads as a surface and the content area stays the darker
|
||||
// `default`. Opaque, so the blur it used to carry is gone.
|
||||
bgcolor={theme.palette.background.paper}
|
||||
sx={{
|
||||
touchAction: "none",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { logger } from "@/Utils/logger";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: T;
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { toast, type ToastOptions } from "react-toastify";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const useToast = () => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
return useMemo(() => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "warning" });
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
const toastWarning = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "warning" });
|
||||
|
||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
|
||||
}, []);
|
||||
};
|
||||
@@ -77,6 +77,13 @@ export const getMonitorDefaults = (
|
||||
...base,
|
||||
type: "docker",
|
||||
url: data?.url || "",
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
dockerTlsCa: data?.dockerTlsCa || "",
|
||||
dockerTlsCert: data?.dockerTlsCert || "",
|
||||
// The key is write-only; the API never returns it.
|
||||
dockerTlsKey: "",
|
||||
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||
};
|
||||
break;
|
||||
case "game":
|
||||
|
||||
@@ -50,6 +50,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
|
||||
import {
|
||||
stepFieldsFor,
|
||||
monitorStepCount,
|
||||
isDockerTlsUrl,
|
||||
type MonitorFormData,
|
||||
} from "@/Validation/monitor";
|
||||
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
|
||||
@@ -66,6 +67,14 @@ const httpMethodOptions = HttpMethods.map((method) => ({
|
||||
label: method,
|
||||
}));
|
||||
|
||||
const PEM_FIELD_ROWS = 6;
|
||||
|
||||
// Hides the pasted key while the field is not focused. Ignored by browsers
|
||||
// without -webkit-text-security, which then show the key as plain text.
|
||||
const maskedInputSx = {
|
||||
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
|
||||
};
|
||||
|
||||
interface GeneralSettingsConfig {
|
||||
urlLabel: string;
|
||||
urlPlaceholder: string;
|
||||
@@ -118,8 +127,8 @@ const getGeneralSettingsConfig = (
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
docker: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
|
||||
urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showProxy: false,
|
||||
@@ -255,6 +264,7 @@ const CreateMonitorPage = () => {
|
||||
const firstSegment = pathSegments[0];
|
||||
if (firstSegment === "pagespeed") return "pagespeed";
|
||||
if (firstSegment === "infrastructure") return "hardware";
|
||||
if (firstSegment === "docker") return "docker";
|
||||
return "uptime";
|
||||
}, [location.pathname]);
|
||||
|
||||
@@ -264,7 +274,9 @@ const CreateMonitorPage = () => {
|
||||
? "pagespeed"
|
||||
: pageType === "hardware"
|
||||
? "hardware"
|
||||
: "http";
|
||||
: pageType === "docker"
|
||||
? "docker"
|
||||
: "http";
|
||||
|
||||
const { data: existingMonitor, refetch: refetchMonitor } = useGet<Monitor>(
|
||||
isEditMode ? `/monitors/${monitorId}` : null
|
||||
@@ -297,6 +309,7 @@ const CreateMonitorPage = () => {
|
||||
const showStep = (step: number) => isEditMode || currentStep === step;
|
||||
|
||||
const watchedType = watch("type") as MonitorType;
|
||||
const watchedUrl = watch("url") as string;
|
||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||
@@ -331,6 +344,8 @@ const CreateMonitorPage = () => {
|
||||
[watchedType, t]
|
||||
);
|
||||
|
||||
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
|
||||
|
||||
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
|
||||
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
|
||||
const isSubmitting = isCreating || isUpdating;
|
||||
@@ -373,6 +388,8 @@ const CreateMonitorPage = () => {
|
||||
navigate("/pagespeed");
|
||||
} else if (pageType === "hardware") {
|
||||
navigate("/infrastructure");
|
||||
} else if (pageType === "docker") {
|
||||
navigate("/docker");
|
||||
} else {
|
||||
navigate("/uptime");
|
||||
}
|
||||
@@ -684,6 +701,64 @@ 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) && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.frequency.title")}
|
||||
@@ -823,6 +898,19 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(1) && watchedType === "docker" && (
|
||||
<ConfigBox
|
||||
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
||||
rightContent={
|
||||
<FormSwitchField
|
||||
name="dockerLogsEnabled"
|
||||
label={t("pages.createMonitor.form.dockerLogs.option.enabled.label")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStep(2) &&
|
||||
(watchedType === "http" ||
|
||||
watchedType === "grpc" ||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,69 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Dot } from "@/Components/design-elements";
|
||||
|
||||
// Hooks
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils";
|
||||
|
||||
interface HeaderLogsProps {
|
||||
lines: number;
|
||||
loading: boolean;
|
||||
lastUpdatedAt: number | null;
|
||||
}
|
||||
|
||||
export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (lastUpdatedAt === null) return;
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND);
|
||||
return () => window.clearInterval(id);
|
||||
}, [lastUpdatedAt]);
|
||||
|
||||
const renderStatus = () => {
|
||||
if (loading) return t("pages.docker.container.logs.loading");
|
||||
if (lastUpdatedAt === null) return null;
|
||||
const ageMs = Math.max(0, now - lastUpdatedAt);
|
||||
return ageMs < MS_PER_MINUTE
|
||||
? t("pages.docker.container.logs.updatedSecondsAgo", {
|
||||
count: Math.floor(ageMs / MS_PER_SECOND),
|
||||
})
|
||||
: t("pages.docker.container.logs.updatedMinutesAgo", {
|
||||
count: Math.floor(ageMs / MS_PER_MINUTE),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.MD}
|
||||
>
|
||||
<Typography>
|
||||
{t("pages.docker.container.logs.recentLines", { count: lines })}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={SPACING.SM}
|
||||
>
|
||||
{lastUpdatedAt !== null && !loading && (
|
||||
<Dot
|
||||
color={theme.palette.success.main}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<Typography color={theme.palette.text.secondary}>{renderStatus()}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { Fragment } from "react";
|
||||
|
||||
// Hooks
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Utils
|
||||
import type { DockerLogRow } from "@/Utils/MonitorUtils";
|
||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/store";
|
||||
|
||||
export const RowLog = ({ row }: { row: DockerLogRow }) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Grid
|
||||
container
|
||||
spacing={{ xs: 0, md: LAYOUT.MD }}
|
||||
py={{ xs: LAYOUT.LG, md: 0 }}
|
||||
>
|
||||
<Grid size={{ xs: 12, md: "auto" }}>
|
||||
<Typography
|
||||
fontFamily="monospace"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: "grow" }}>
|
||||
<Typography
|
||||
fontFamily={"monospace"}
|
||||
whiteSpace={{ xs: "pre-wrap", md: "nowrap" }}
|
||||
sx={{
|
||||
overflowWrap: { xs: "anywhere", md: "normal" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={"span"}
|
||||
color={
|
||||
row.stream === "stderr"
|
||||
? theme.palette.error.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
>
|
||||
{row.stream}
|
||||
</Box>
|
||||
: {row.text}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{row.gapBefore ? (
|
||||
<Stack
|
||||
width="100%"
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
py={SPACING.MD}
|
||||
>
|
||||
<Typography pr={SPACING.MD}>
|
||||
{t("pages.docker.container.logs.skipped")}
|
||||
</Typography>
|
||||
<Divider sx={{ flex: 1 }} />
|
||||
</Stack>
|
||||
) : (
|
||||
<Divider sx={{ display: { xs: "block", md: "none" } }} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
|
||||
|
||||
// Utils
|
||||
import { formatDuration } from "@/Utils/TimeUtils";
|
||||
import { getDockerPalette } from "@/Utils/MonitorUtils";
|
||||
|
||||
export const DockerContainerStatusBoxes = ({
|
||||
container,
|
||||
@@ -24,6 +25,7 @@ export const DockerContainerStatusBoxes = ({
|
||||
const { state, restartCount, health } = container;
|
||||
const startedAt = container.startedAt ?? "0";
|
||||
const runningFor = Date.now() - new Date(startedAt).getTime();
|
||||
const palette = getDockerPalette(state);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -33,6 +35,7 @@ export const DockerContainerStatusBoxes = ({
|
||||
<StatBox
|
||||
title={t("common.labels.state")}
|
||||
subtitle={state}
|
||||
palette={palette}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("common.labels.uptime")}
|
||||
|
||||
@@ -1,3 +1,252 @@
|
||||
export const TabLogs = () => {
|
||||
return "This is the logs tab";
|
||||
// Components
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, EmptyState } from "@/Components/design-elements";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs";
|
||||
import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog";
|
||||
|
||||
// Types
|
||||
import type { DockerContainerLogsResponse } from "@/Types/Monitor";
|
||||
import type { DockerLog } from "@/Types/Check";
|
||||
import type { ApiResponse } from "@/Hooks/UseApi";
|
||||
|
||||
// Hooks
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
|
||||
// Utils
|
||||
import { get } from "@/Utils/ApiClient";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { flattenDockerLogs } from "@/Utils/MonitorUtils";
|
||||
|
||||
interface TabLogsProps {
|
||||
monitorId?: string;
|
||||
containerName?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const SCROLL_PIN_THRESHOLD_PX = 8;
|
||||
const TAIL_POLL_MS = 10_000;
|
||||
|
||||
const isScrolledToBottom = (el: HTMLElement) =>
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX;
|
||||
|
||||
const fetchLogs = async (url: string, params: { before?: string; after?: string }) => {
|
||||
const res = await get<ApiResponse<DockerContainerLogsResponse>>(url, { params });
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { toastError } = useToast();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// logs is newest-first, matching the API and what flattenDockerLogs expects.
|
||||
const [logs, setLogs] = useState<DockerLog[]>([]);
|
||||
const [olderExhausted, setOlderExhausted] = useState(false);
|
||||
const [loadingInitial, setLoadingInitial] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
|
||||
|
||||
const logsUrl =
|
||||
isAdmin && enabled && monitorId && containerName
|
||||
? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs`
|
||||
: null;
|
||||
|
||||
const newestCursor = logs[0]?.checkedAt;
|
||||
const oldestCursor = logs[logs.length - 1]?.checkedAt;
|
||||
|
||||
// Initial page. Resets everything when the container changes.
|
||||
useEffect(() => {
|
||||
setLogs([]);
|
||||
setOlderExhausted(false);
|
||||
if (!logsUrl) return;
|
||||
let cancelled = false;
|
||||
setLoadingInitial(true);
|
||||
fetchLogs(logsUrl, {})
|
||||
.then((page) => {
|
||||
if (cancelled) return;
|
||||
setLogs(page.logs);
|
||||
setOlderExhausted(page.nextCursor === null);
|
||||
setLastUpdatedAt(Date.now());
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
toastError(t("pages.docker.container.logs.loadError"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingInitial(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [logsUrl, t, toastError]);
|
||||
|
||||
const newestCursorRef = useRef(newestCursor);
|
||||
newestCursorRef.current = newestCursor;
|
||||
|
||||
useEffect(() => {
|
||||
if (!logsUrl) return;
|
||||
let inFlight = false;
|
||||
const tick = async () => {
|
||||
if (inFlight || document.hidden) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const cursor = newestCursorRef.current;
|
||||
const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {});
|
||||
if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]);
|
||||
setLastUpdatedAt(Date.now());
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
const id = window.setInterval(tick, TAIL_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [logsUrl]);
|
||||
|
||||
const rows = useMemo(() => flattenDockerLogs(logs), [logs]);
|
||||
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const pinnedToBottom = useRef(true);
|
||||
const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||
|
||||
// Always pin to bottom when container/monitor changes
|
||||
useLayoutEffect(() => {
|
||||
pinnedToBottom.current = true;
|
||||
}, [monitorId, containerName]);
|
||||
|
||||
// Rows changed: restore position after a prepend, otherwise honour the pin
|
||||
useLayoutEffect(() => {
|
||||
const el = logBoxRef.current;
|
||||
if (!el) return;
|
||||
const anchor = prependAnchor.current;
|
||||
if (anchor) {
|
||||
prependAnchor.current = null;
|
||||
el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop;
|
||||
return;
|
||||
}
|
||||
if (pinnedToBottom.current) el.scrollTop = el.scrollHeight;
|
||||
}, [rows]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
pinnedToBottom.current = isScrolledToBottom(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!logsUrl || !oldestCursor || loadingOlder) return;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const page = await fetchLogs(logsUrl, { before: oldestCursor });
|
||||
const el = logBoxRef.current;
|
||||
if (el)
|
||||
prependAnchor.current = {
|
||||
scrollHeight: el.scrollHeight,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
setLogs((prev) => [...prev, ...page.logs]);
|
||||
setOlderExhausted(page.nextCursor === null);
|
||||
} catch {
|
||||
toastError(t("pages.docker.container.logs.loadOlderError"));
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [logsUrl, oldestCursor, loadingOlder, t, toastError]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.forbidden.title")}
|
||||
description={t("pages.docker.container.logs.forbidden.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.disabled.title")}
|
||||
description={t("pages.docker.container.logs.disabled.description")}
|
||||
actionText={t("pages.docker.container.logs.disabled.action")}
|
||||
actionTo={`/docker/configure/${monitorId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastUpdatedAt !== null && rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("pages.docker.container.logs.empty.title")}
|
||||
description={t("pages.docker.container.logs.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={LAYOUT.MD}>
|
||||
<HeaderLogs
|
||||
lines={rows.length}
|
||||
loading={loadingInitial}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
/>
|
||||
<BaseBox
|
||||
ref={logBoxRef}
|
||||
onScroll={handleScroll}
|
||||
minHeight={240}
|
||||
maxHeight="50vh"
|
||||
overflow="auto"
|
||||
padding={LAYOUT.MD}
|
||||
sx={{ overflowAnchor: "none" }}
|
||||
>
|
||||
{rows.length > 0 && (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
pb={LAYOUT.MD}
|
||||
>
|
||||
{olderExhausted ? (
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
{t("pages.docker.container.logs.noOlder")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={loadingOlder}
|
||||
onClick={loadOlder}
|
||||
>
|
||||
{loadingOlder
|
||||
? t("pages.docker.container.logs.loadingOlder")
|
||||
: t("pages.docker.container.logs.loadOlder")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
return (
|
||||
<RowLog
|
||||
key={row.key}
|
||||
row={row}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BaseBox>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { formatPercentage } from "@/Utils/FormatUtils";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { BaseBox } from "@/Components/design-elements";
|
||||
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
||||
import type { DateRange } from "@/Types/Query";
|
||||
|
||||
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
|
||||
{
|
||||
@@ -24,20 +25,29 @@ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats
|
||||
key: "avgCpuPct",
|
||||
color: theme.palette.success.main,
|
||||
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
|
||||
formatter: formatPercentage,
|
||||
labelKey: "common.charts.histogram.avgCpuUsage",
|
||||
},
|
||||
{
|
||||
title: t("common.charts.labels.memoryUsage"),
|
||||
key: "avgMemoryUsedBytes",
|
||||
color: theme.palette.primary.main,
|
||||
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
|
||||
formatter: prettyBytes,
|
||||
labelKey: "common.charts.histogram.avgMemoryUsage",
|
||||
},
|
||||
];
|
||||
|
||||
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
||||
export const TabOverview = ({
|
||||
stats,
|
||||
range,
|
||||
}: {
|
||||
stats: DockerContainerStats;
|
||||
range: DateRange;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const { ports, mounts } = stats?.latest?.container ?? {};
|
||||
const dedupedPorts = dedupeDockerPorts(ports ?? []);
|
||||
return (
|
||||
@@ -58,6 +68,9 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
|
||||
dataKey={config.key}
|
||||
strokeColor={config.color}
|
||||
gradientStartColor={config.color}
|
||||
range={range}
|
||||
formatter={config.formatter}
|
||||
labelKey={config.labelKey}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -69,8 +69,19 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
|
||||
<Tab label={t("pages.docker.container.tabs.overview")} />
|
||||
<Tab label={t("pages.docker.container.tabs.logs")} />
|
||||
</Tabs>
|
||||
{activeTab === 0 && <TabOverview stats={stats} />}
|
||||
{activeTab === 1 && <TabLogs />}
|
||||
{activeTab === 0 && (
|
||||
<TabOverview
|
||||
stats={stats}
|
||||
range={dateRange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 1 && (
|
||||
<TabLogs
|
||||
monitorId={monitorId}
|
||||
containerName={containerName}
|
||||
enabled={monitor.dockerLogsEnabled ?? false}
|
||||
/>
|
||||
)}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,9 +30,6 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
// The icons are supporting marks beside the label, not content,
|
||||
// so they take secondary ink rather than lucide's currentColor
|
||||
// default, which renders them heavier than the text.
|
||||
sx={{ "& svg": { color: theme.palette.text.secondary } }}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -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 Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
|
||||
alignItems="center"
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={NotFoundSvg}
|
||||
alt="404"
|
||||
maxHeight={"25rem"}
|
||||
component={NotFoundSvg}
|
||||
role="img"
|
||||
aria-label="404"
|
||||
width="100%"
|
||||
maxHeight="25rem"
|
||||
/>
|
||||
<Typography
|
||||
component="h1"
|
||||
|
||||
@@ -126,6 +126,7 @@ const NotificationsCreatePage = () => {
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "signalgrid" &&
|
||||
watchedType !== "twilio" &&
|
||||
watchedType !== "ntfy" && (
|
||||
<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" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.twilio.title")}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// No "Inter" here: its webfont was never self-hosted, so once the Google Fonts
|
||||
// import went it resolved to a fallback anyway. Listing it only made the stack
|
||||
// look intentional on machines that happen to have Inter installed.
|
||||
export const SANS_STACK =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsT
|
||||
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const c = useMonitorListController({
|
||||
const monitorListController = useMonitorListController({
|
||||
types: "selectable",
|
||||
checksLimit: 25,
|
||||
refreshInterval: 5000,
|
||||
@@ -17,25 +17,25 @@ const UptimeMonitorsPage = () => {
|
||||
headerKey="uptime"
|
||||
page="uptime"
|
||||
actionLink="/uptime/create"
|
||||
controller={c}
|
||||
controller={monitorListController}
|
||||
bulkActions
|
||||
>
|
||||
<MonitorTable
|
||||
monitors={c.monitors || []}
|
||||
tags={c.tags || []}
|
||||
refetch={c.refetch}
|
||||
setSelectedMonitor={c.setSelectedMonitor}
|
||||
count={c.count || 0}
|
||||
page={c.page}
|
||||
setPage={c.setPage}
|
||||
rowsPerPage={c.rowsPerPage}
|
||||
sortField={c.sortField}
|
||||
setSortField={c.setSortField}
|
||||
sortOrder={c.sortOrder}
|
||||
setSortOrder={c.setSortOrder}
|
||||
setRowsPerPage={c.handleSetRowsPerPage}
|
||||
selectedRows={c.selectedRows}
|
||||
onSelectionChange={c.setSelectedRows}
|
||||
monitors={monitorListController.monitors || []}
|
||||
tags={monitorListController.tags || []}
|
||||
refetch={monitorListController.refetch}
|
||||
setSelectedMonitor={monitorListController.setSelectedMonitor}
|
||||
count={monitorListController.count || 0}
|
||||
page={monitorListController.page}
|
||||
setPage={monitorListController.setPage}
|
||||
rowsPerPage={monitorListController.rowsPerPage}
|
||||
sortField={monitorListController.sortField}
|
||||
setSortField={monitorListController.setSortField}
|
||||
sortOrder={monitorListController.sortOrder}
|
||||
setSortOrder={monitorListController.setSortOrder}
|
||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||
selectedRows={monitorListController.selectedRows}
|
||||
onSelectionChange={monitorListController.setSelectedRows}
|
||||
/>
|
||||
</MonitorListPage>
|
||||
);
|
||||
|
||||
@@ -161,6 +161,31 @@ export interface DockerContainerMount {
|
||||
rw: boolean;
|
||||
}
|
||||
|
||||
export const DockerLogStreams = ["stdout", "stderr"] as const;
|
||||
export type DockerLogStream = (typeof DockerLogStreams)[number];
|
||||
|
||||
export interface DockerLogLine {
|
||||
ts: string;
|
||||
stream: DockerLogStream;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DockerLog {
|
||||
id: string;
|
||||
metadata: {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
};
|
||||
lines: DockerLogLine[];
|
||||
gap: boolean;
|
||||
checkedAt: string;
|
||||
expiry: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DailyCheckBucket,
|
||||
DockerContainerInfo,
|
||||
DockerContainerSummary,
|
||||
DockerLog,
|
||||
} from "@/Types/Check";
|
||||
import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
@@ -158,6 +159,10 @@ export interface Monitor {
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dockerLogsEnabled?: boolean;
|
||||
dockerTlsCa?: string;
|
||||
dockerTlsCert?: string;
|
||||
dockerTlsKeySet?: boolean;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
@@ -322,6 +327,10 @@ export interface DockerContainerResponse {
|
||||
stats: DockerContainerStats;
|
||||
}
|
||||
|
||||
export interface DockerContainerLogsResponse {
|
||||
logs: DockerLog[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
export interface Game {
|
||||
name: string;
|
||||
options?: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"signalgrid",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import type {
|
||||
DockerContainerMount,
|
||||
DockerContainerPort,
|
||||
DockerContainerState,
|
||||
import {
|
||||
DockerLogStreams,
|
||||
type DockerContainerMount,
|
||||
type DockerContainerPort,
|
||||
type DockerContainerState,
|
||||
type DockerLog,
|
||||
type DockerLogLine,
|
||||
} from "@/Types/Check";
|
||||
|
||||
export const getMonitorPath = (type: MonitorType): string => {
|
||||
@@ -37,6 +40,14 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => {
|
||||
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 => {
|
||||
const paletteMap: Record<ValueType, PaletteKey> = {
|
||||
positive: "success",
|
||||
@@ -144,3 +155,35 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => {
|
||||
if (mount.source) return mount.source;
|
||||
return mount.type;
|
||||
};
|
||||
|
||||
export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const;
|
||||
export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number];
|
||||
|
||||
export interface DockerLogRow extends DockerLogLine {
|
||||
key: string;
|
||||
gapBefore: boolean; // This signifies lines skipped
|
||||
}
|
||||
|
||||
export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => {
|
||||
const rows: DockerLogRow[] = [];
|
||||
for (const log of [...logs].reverse()) {
|
||||
log.lines.forEach((line: DockerLogLine, idx: number) => {
|
||||
rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap });
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const filterDockerLogRows = (
|
||||
rows: DockerLogRow[],
|
||||
stream: DockerLogStreamFilter,
|
||||
query: string
|
||||
): DockerLogRow[] => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return (
|
||||
(stream === "all" || row.stream === stream) &&
|
||||
(normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery))
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -18,14 +18,11 @@ export const colors = {
|
||||
gray700: "#313131",
|
||||
gray900: "#151518",
|
||||
gray850: "#1c1c21",
|
||||
// Neutral ramp, biased slightly green so it sits with the brand rather than
|
||||
// against it. Opaque values - alpha-black over white reads washed out.
|
||||
ink900: "#101917",
|
||||
ink600: "#55635C",
|
||||
ink400: "#7C8983",
|
||||
surface50: "#F6F8F7",
|
||||
rule200: "#C7D2CC",
|
||||
// Semantic colours derived from the brand rather than Material's defaults
|
||||
successMain: "#0D6049",
|
||||
errorMain: "#A02B24",
|
||||
warningMain: "#8C5806",
|
||||
@@ -60,8 +57,6 @@ export const lightPalette = {
|
||||
action: {
|
||||
rowHover: alpha(colors.gray900, HOVER.ROW),
|
||||
controlHover: alpha(colors.gray900, HOVER.CONTROL),
|
||||
// Tinted with the brand so a selected row reads as chosen rather than
|
||||
// merely dirty, and the hover step stays clearly above the resting one.
|
||||
selected: alpha(colors.brandGreen, 0.06),
|
||||
selectedHover: alpha(colors.brandGreen, 0.1),
|
||||
},
|
||||
@@ -78,9 +73,6 @@ export const lightPalette = {
|
||||
disabled: colors.ink400,
|
||||
},
|
||||
divider: colors.rule200,
|
||||
// Only `main` is set. MUI derives `light`/`dark` from it, and `light` is
|
||||
// consumed as a saturated colour - status dots, histogram bars, heatmap
|
||||
// cells - so it must not be overridden with a pale tint.
|
||||
success: {
|
||||
main: colors.successMain,
|
||||
},
|
||||
@@ -112,10 +104,6 @@ export const lightPalette = {
|
||||
export const darkPalette = {
|
||||
primary: {
|
||||
main: colors.brandGreen,
|
||||
// The dark ground needs a lighter green for anything drawn as a line
|
||||
// rather than a fill - focus rings especially, which were dimmer than
|
||||
// their own hover state. Leaving `main` alone keeps contained buttons
|
||||
// white-on-green instead of flipping them to dark text.
|
||||
light: colors.brandGreenLight,
|
||||
},
|
||||
action: {
|
||||
|
||||
@@ -134,9 +134,6 @@ export const theme = (mode: string, palette: any) =>
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
"&.Mui-focusVisible": {
|
||||
// Same green as the input focus ring - `main` is only
|
||||
// 2.86:1 on the dark ground, so keyboard focus was
|
||||
// barely visible there.
|
||||
outline: `1px solid ${
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.primary.light
|
||||
@@ -195,24 +192,14 @@ export const theme = (mode: string, palette: any) =>
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
// One border story for every outlined input - text fields, selects,
|
||||
// autocompletes, date and colour pickers all route through this.
|
||||
// Resting: divider. Hover: a step darker, so there is a cue without
|
||||
// jumping to near-black. Focus: the brand, at 2px.
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
// MUI pads the inner input 8.5px top and bottom, which with an
|
||||
// 18.7px line-height needs 35.7px - taller than the control
|
||||
// itself, so the text was being clipped by overflow:hidden.
|
||||
input: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: "100%",
|
||||
},
|
||||
root: ({ theme }) => ({
|
||||
// The Select renders a div rather than an input, so it needs
|
||||
// the same treatment to sit inside the control height.
|
||||
"& .MuiSelect-select": {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
|
||||
@@ -18,12 +18,7 @@ export const LAYOUT = {
|
||||
XXL: 16,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shared height for anything that can sit in a row with something else -
|
||||
* buttons, inputs, selects, pickers, toggles, tabs, icon buttons. One number so
|
||||
* they share a baseline instead of drifting 2px apart.
|
||||
*/
|
||||
export const CONTROL_HEIGHT = 32;
|
||||
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
|
||||
|
||||
export const HOVER = {
|
||||
DARKEN: 0.06, // This is a coefficient for darkening function
|
||||
|
||||
@@ -108,10 +108,31 @@ const portSchema = baseSchema.extend({
|
||||
.max(65535, "Port must be at most 65535"),
|
||||
});
|
||||
|
||||
// Docker monitor schema
|
||||
// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
|
||||
// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts.
|
||||
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
|
||||
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
|
||||
|
||||
export const isDockerTlsUrl = (url: string | undefined): boolean =>
|
||||
dockerTlsUrlRegex.test(url?.trim() ?? "");
|
||||
|
||||
const dockerSchema = baseSchema.extend({
|
||||
type: z.literal("docker"),
|
||||
url: z.string().min(1, "Container ID is required"),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "Docker host URL is required")
|
||||
.refine(
|
||||
(url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url),
|
||||
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
|
||||
),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
dockerTlsCa: z.string(),
|
||||
dockerTlsCert: z.string(),
|
||||
// blank on edit keeps the stored key.
|
||||
dockerTlsKey: z.string(),
|
||||
// Read only mirror of the server flag, stripped on submit
|
||||
dockerTlsKeySet: z.boolean(),
|
||||
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||
});
|
||||
|
||||
// Game server monitor schema
|
||||
@@ -216,7 +237,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
|
||||
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) => {
|
||||
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
|
||||
ctx.addIssue({
|
||||
@@ -225,6 +247,30 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
|
||||
message: "A proxy must be selected when proxy mode is custom",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
|
||||
if (!data.dockerTlsCert.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["dockerTlsCert"],
|
||||
message: "Client certificate is required for a TLS Docker host",
|
||||
});
|
||||
}
|
||||
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["dockerTlsKey"],
|
||||
message: "Client key is required for a TLS Docker host",
|
||||
});
|
||||
}
|
||||
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["dockerTlsCa"],
|
||||
message: "CA certificate is required unless TLS errors are ignored",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||
|
||||
@@ -70,6 +70,12 @@ const pushoverSchema = baseSchema.extend({
|
||||
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({
|
||||
type: z.literal("twilio"),
|
||||
accountSid: z.string().min(1, "Account SID is required"),
|
||||
@@ -95,6 +101,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
teamsSchema,
|
||||
telegramSchema,
|
||||
pushoverSchema,
|
||||
signalgridSchema,
|
||||
twilioSchema,
|
||||
ntfySchema,
|
||||
]);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
|
Before Width: | Height: | Size: 536 KiB After Width: | Height: | Size: 286 KiB |
@@ -80,7 +80,9 @@
|
||||
"avg": "Avg: {{value}}",
|
||||
"max": "Max: {{value}}",
|
||||
"responseTime": "Response time: {{value}}",
|
||||
"total": "Total: {{value}}"
|
||||
"total": "Total: {{value}}",
|
||||
"avgCpuUsage": "Average CPU usage: {{value}}",
|
||||
"avgMemoryUsage": "Average memory usage: {{value}}"
|
||||
},
|
||||
"phases": {
|
||||
"dns": "DNS",
|
||||
@@ -632,9 +634,9 @@
|
||||
},
|
||||
"general": {
|
||||
"option": {
|
||||
"container": {
|
||||
"label": "Container name/ID",
|
||||
"placeholder": "my-app or abcd1234"
|
||||
"dockerHost": {
|
||||
"label": "Docker host",
|
||||
"placeholder": "unix:///var/run/docker.sock"
|
||||
},
|
||||
"host": {
|
||||
"label": "Host",
|
||||
@@ -716,7 +718,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.",
|
||||
"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.",
|
||||
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
|
||||
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. Every container on the host is monitored.",
|
||||
"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.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
@@ -756,7 +758,7 @@
|
||||
},
|
||||
"type": {
|
||||
"description": "Select the type of check to perform",
|
||||
"optionDockerDescription": "Use Docker to monitor if a container is running.",
|
||||
"optionDockerDescription": "Monitor a Docker host and every container running on it.",
|
||||
"optionGameDescription": "Monitor if a specific game server is online.",
|
||||
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
|
||||
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
|
||||
@@ -814,6 +816,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerLogs": {
|
||||
"title": "Container logs",
|
||||
"description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.",
|
||||
"option": {
|
||||
"enabled": {
|
||||
"label": "Collect container logs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Monitor history",
|
||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||
@@ -823,6 +834,29 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -956,6 +990,31 @@
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"logs": {
|
||||
"recentLines": "{{count}} recent lines",
|
||||
"loading": "Loading...",
|
||||
"updatedSecondsAgo": "Updated {{count}}s ago",
|
||||
"updatedMinutesAgo": "Updated {{count}}m ago",
|
||||
"skipped": "Some log entries were skipped",
|
||||
"loadOlder": "Load older logs",
|
||||
"loadingOlder": "Loading...",
|
||||
"noOlder": "Beginning of retained logs",
|
||||
"loadError": "Could not load logs",
|
||||
"loadOlderError": "Could not load older logs",
|
||||
"empty": {
|
||||
"title": "No logs collected yet",
|
||||
"description": "Lines will appear here after the next check. Only output written since collection was turned on is stored."
|
||||
},
|
||||
"disabled": {
|
||||
"title": "Log collection is off for this host",
|
||||
"description": "Turn on container logs in the monitor settings to start collecting.",
|
||||
"action": "Configure monitor"
|
||||
},
|
||||
"forbidden": {
|
||||
"title": "You don't have access to container logs",
|
||||
"description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
|
||||
}
|
||||
}
|
||||
},
|
||||
"host": {
|
||||
@@ -1217,7 +1276,7 @@
|
||||
},
|
||||
"startTime": {
|
||||
"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": {
|
||||
"duration": {
|
||||
"label": "Duration"
|
||||
@@ -1299,6 +1358,7 @@
|
||||
"teams": "Teams",
|
||||
"telegram": "Telegram",
|
||||
"pushover": "Pushover",
|
||||
"signalgrid": "Signalgrid",
|
||||
"twilio": "Twilio",
|
||||
"ntfy": "ntfy"
|
||||
}
|
||||
@@ -1319,6 +1379,14 @@
|
||||
"optionUserKey": "User key",
|
||||
"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": {
|
||||
"title": "Twilio SMS configuration",
|
||||
"description": "Configure Twilio to send SMS notifications to a phone number.",
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
|
||||
- CLIENT_HOST=${CLIENT_HOST:-http://localhost:52345}
|
||||
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
|
||||
stop_grace_period: 60s
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
@@ -24,6 +24,8 @@ services:
|
||||
checkmate-demo:
|
||||
image: ghcr.io/bluewave-labs/checkmate:master
|
||||
container_name: checkmate-demo
|
||||
group_add:
|
||||
- "989"
|
||||
restart: always
|
||||
env_file:
|
||||
- server-prod.env
|
||||
@@ -68,6 +70,8 @@ services:
|
||||
checkmate-staging:
|
||||
image: ghcr.io/bluewave-labs/checkmate:develop
|
||||
container_name: checkmate-staging
|
||||
group_add:
|
||||
- "989"
|
||||
restart: always
|
||||
env_file:
|
||||
- server-staging.env
|
||||
|
||||
@@ -27,6 +27,17 @@ CLIENT_HOST=http://localhost:5173
|
||||
# ----------------------
|
||||
ORIGIN=localhost
|
||||
|
||||
# Encryption
|
||||
# ------------------
|
||||
# Encrypts stored Docker TLS client keys. Not needed for socket-only Docker monitors.
|
||||
# Generate a key: openssl rand -base64 32
|
||||
# Key list: comma-separated; the first key encrypts, every key decrypts.
|
||||
# Split deployments: set the same value on the API and every worker.
|
||||
# Rotate a key: 1. deploy OLD_KEY,NEW_KEY everywhere so every process can read both
|
||||
# 2. deploy NEW_KEY,OLD_KEY everywhere so new writes use the new key
|
||||
# 3. wait for the worker to re-encrypt every row, then drop OLD_KEY
|
||||
# ENCRYPTION_KEY=<output of openssl rand -base64 32>
|
||||
|
||||
# Feature Flags
|
||||
# -------------
|
||||
# Set to "false" to disable the selectable theme feature on status pages.
|
||||
|
||||
+385
-21
@@ -422,9 +422,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -466,6 +463,18 @@
|
||||
"geoCheckInterval": {
|
||||
"type": "number"
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dockerTlsCa": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsCert": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsKeySet": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -513,6 +522,7 @@
|
||||
"geoCheckEnabled",
|
||||
"geoCheckLocations",
|
||||
"geoCheckInterval",
|
||||
"dockerLogsEnabled",
|
||||
"teamId",
|
||||
"userId",
|
||||
"createdAt",
|
||||
@@ -640,9 +650,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -684,6 +691,18 @@
|
||||
"geoCheckInterval": {
|
||||
"type": "number"
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dockerTlsCa": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsCert": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsKeySet": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -733,6 +752,7 @@
|
||||
"geoCheckEnabled",
|
||||
"geoCheckLocations",
|
||||
"geoCheckInterval",
|
||||
"dockerLogsEnabled",
|
||||
"teamId",
|
||||
"userId",
|
||||
"createdAt",
|
||||
@@ -929,6 +949,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/PushoverNotification"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SignalgridNotification"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/TwilioNotification"
|
||||
},
|
||||
@@ -949,6 +972,7 @@
|
||||
"teams": "#/components/schemas/TeamsNotification",
|
||||
"telegram": "#/components/schemas/TelegramNotification",
|
||||
"pushover": "#/components/schemas/PushoverNotification",
|
||||
"signalgrid": "#/components/schemas/SignalgridNotification",
|
||||
"twilio": "#/components/schemas/TwilioNotification",
|
||||
"ntfy": "#/components/schemas/NtfyNotification"
|
||||
}
|
||||
@@ -1380,6 +1404,34 @@
|
||||
"accessToken": "a1234567890abcdef"
|
||||
}
|
||||
},
|
||||
"SignalgridNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notificationName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["signalgrid"]
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"accessToken": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["notificationName", "type", "address", "accessToken"],
|
||||
"example": {
|
||||
"notificationName": "Signalgrid",
|
||||
"type": "signalgrid",
|
||||
"address": "your-channel",
|
||||
"accessToken": "your-client-key"
|
||||
}
|
||||
},
|
||||
"TwilioNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7090,9 +7142,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -7134,6 +7183,18 @@
|
||||
"geoCheckInterval": {
|
||||
"type": "number"
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dockerTlsCa": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsCert": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsKeySet": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7183,6 +7244,7 @@
|
||||
"geoCheckEnabled",
|
||||
"geoCheckLocations",
|
||||
"geoCheckInterval",
|
||||
"dockerLogsEnabled",
|
||||
"teamId",
|
||||
"userId",
|
||||
"createdAt",
|
||||
@@ -7655,9 +7717,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -7699,6 +7758,18 @@
|
||||
"geoCheckInterval": {
|
||||
"type": "number"
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dockerTlsCa": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsCert": {
|
||||
"type": "string"
|
||||
},
|
||||
"dockerTlsKeySet": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7748,6 +7819,7 @@
|
||||
"geoCheckEnabled",
|
||||
"geoCheckLocations",
|
||||
"geoCheckInterval",
|
||||
"dockerLogsEnabled",
|
||||
"teamId",
|
||||
"userId",
|
||||
"createdAt",
|
||||
@@ -7984,6 +8056,231 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/monitors/docker/details/{monitorId}/containers/{containerName}/logs": {
|
||||
"get": {
|
||||
"tags": ["monitors"],
|
||||
"summary": "Get stored log lines for one container on a Docker host monitor, newest first",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"required": true,
|
||||
"name": "monitorId",
|
||||
"in": "path"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"required": true,
|
||||
"name": "containerName",
|
||||
"in": "path"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"required": false,
|
||||
"name": "before",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"required": false,
|
||||
"name": "after",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 20
|
||||
},
|
||||
"required": false,
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"logs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"monitorId": {
|
||||
"type": "string"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "string"
|
||||
},
|
||||
"containerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"containerName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["monitorId", "teamId", "containerId", "containerName"]
|
||||
},
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ts": {
|
||||
"type": "string"
|
||||
},
|
||||
"stream": {
|
||||
"type": "string",
|
||||
"enum": ["stdout", "stderr"]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["ts", "stream", "text"]
|
||||
}
|
||||
},
|
||||
"gap": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"checkedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"expiry": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "metadata", "lines", "gap", "checkedAt", "expiry", "createdAt", "updatedAt"]
|
||||
}
|
||||
},
|
||||
"nextCursor": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["logs", "nextCursor"]
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg", "data"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/monitors/pagespeed/details/{monitorId}": {
|
||||
"get": {
|
||||
"tags": ["monitors"],
|
||||
@@ -8908,9 +9205,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"jsonPath": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -9011,6 +9305,42 @@
|
||||
"type": "number",
|
||||
"minimum": 300000
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dockerTlsCa": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [""]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dockerTlsCert": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [""]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dockerTlsKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [""]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
@@ -9604,9 +9934,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"cpuAlertThreshold": {
|
||||
"type": "number",
|
||||
"default": 100
|
||||
@@ -9702,6 +10029,10 @@
|
||||
"minimum": 300000,
|
||||
"default": 300000
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
@@ -10137,9 +10468,6 @@
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"ignoreTlsErrors": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -10269,6 +10597,42 @@
|
||||
"type": "number",
|
||||
"minimum": 300000
|
||||
},
|
||||
"dockerLogsEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"dockerTlsCa": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [""]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dockerTlsCert": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [""]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dockerTlsKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [""]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dnsServer": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
dockerContainerDetailsResponseSchema,
|
||||
monitorResponseSchema,
|
||||
uptimeDetailsResponseSchema,
|
||||
getDockerContainerLogsQueryValidation,
|
||||
dockerContainerLogsResponseSchema,
|
||||
} from "@/api/validation/monitorValidation.js";
|
||||
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
|
||||
|
||||
@@ -131,6 +133,16 @@ registry.registerPath({
|
||||
responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs",
|
||||
tags,
|
||||
summary: "Get stored log lines for one container on a Docker host monitor, newest first",
|
||||
security: bearer,
|
||||
request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation },
|
||||
responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/monitors/pagespeed/details/{monitorId}",
|
||||
|
||||
@@ -65,6 +65,10 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
|
||||
component: "PushoverNotification",
|
||||
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
|
||||
},
|
||||
signalgrid: {
|
||||
component: "SignalgridNotification",
|
||||
example: { notificationName: "Signalgrid", type: "signalgrid", address: "your-channel", accessToken: "your-client-key" },
|
||||
},
|
||||
twilio: {
|
||||
component: "TwilioNotification",
|
||||
example: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from
|
||||
|
||||
import {
|
||||
registrationBodyValidation,
|
||||
registerInviteTokenValidation,
|
||||
loginValidation,
|
||||
recoveryValidation,
|
||||
recoveryTokenBodyValidation,
|
||||
@@ -48,7 +49,7 @@ class AuthController implements IAuthController {
|
||||
|
||||
registerUser = catchAsync(async (req: Request, res: Response) => {
|
||||
const newUser = req.body.user;
|
||||
const newUserToken = req.body.token;
|
||||
const newUserToken = registerInviteTokenValidation.parse(req.body.token);
|
||||
if (newUser?.email) {
|
||||
const newUserEmail = requireUserEmail(newUser.email);
|
||||
newUser.email = newUserEmail.toLowerCase();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getDockerDetailsByIdQueryValidation,
|
||||
getDockerContainerNameParamValidation,
|
||||
getDockerContainerByNameQueryValidation,
|
||||
getDockerContainerLogsQueryValidation,
|
||||
} from "@/api/validation/monitorValidation.js";
|
||||
import sslChecker from "ssl-checker";
|
||||
import * as whoiser from "whoiser";
|
||||
@@ -37,6 +38,7 @@ export interface IMonitorController {
|
||||
getPageSpeedDetailsById: RequestHandler;
|
||||
getDockerDetailsById: RequestHandler;
|
||||
getDockerContainerByName: RequestHandler;
|
||||
getDockerContainerLogs: RequestHandler;
|
||||
getGeoChecksByMonitorId: RequestHandler;
|
||||
getMonitorById: RequestHandler;
|
||||
createMonitor: RequestHandler;
|
||||
@@ -198,6 +200,30 @@ class MonitorController implements IMonitorController {
|
||||
});
|
||||
});
|
||||
|
||||
getDockerContainerLogs = catchAsync(async (req: Request, res: Response) => {
|
||||
const validatedParams = getDockerContainerNameParamValidation.parse(req.params);
|
||||
const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query);
|
||||
|
||||
const { monitorId, containerName } = validatedParams;
|
||||
const { before, after, limit } = validatedQuery;
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const data = await this.monitorService.getDockerContainerLogs({
|
||||
teamId,
|
||||
monitorId,
|
||||
containerName,
|
||||
before: before ? new Date(before) : undefined,
|
||||
after: after ? new Date(after) : undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Docker container logs retrieved successfully",
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => {
|
||||
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
|
||||
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
|
||||
|
||||
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
|
||||
router.get("/team", checkController.getChecksByTeam);
|
||||
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
|
||||
router.get("/:monitorId", checkController.getChecksByMonitor);
|
||||
router.delete("/:monitorId", checkController.deleteChecks);
|
||||
router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
|
||||
return router;
|
||||
};
|
||||
@@ -1,15 +1,16 @@
|
||||
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
|
||||
import { isAllowed } from "@/api/middleware/isAllowed.js";
|
||||
import { Router } from "express";
|
||||
|
||||
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
|
||||
const router = Router();
|
||||
router.post("/", maintenanceWindowController.createMaintenanceWindows);
|
||||
router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
|
||||
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
|
||||
|
||||
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
|
||||
|
||||
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
|
||||
router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
|
||||
router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
|
||||
router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
|
||||
router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
|
||||
return router;
|
||||
};
|
||||
@@ -21,6 +21,11 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
|
||||
// Docker routes
|
||||
router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
|
||||
router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
|
||||
router.get(
|
||||
"/docker/details/:monitorId/containers/:containerName/logs",
|
||||
isAllowed(["admin", "superadmin"]),
|
||||
monitorController.getDockerContainerLogs
|
||||
);
|
||||
|
||||
// Geo checks routes
|
||||
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { INotificationController } from "@/api/controllers/notificationController.js";
|
||||
import { isAllowed } from "@/api/middleware/isAllowed.js";
|
||||
import { Router } from "express";
|
||||
|
||||
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
|
||||
const router = Router();
|
||||
router.post("/", notificationController.createNotification);
|
||||
router.post("/test/all", notificationController.testAllNotifications);
|
||||
router.post("/test", notificationController.testNotification);
|
||||
router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
|
||||
router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
|
||||
router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
|
||||
router.get("/team", notificationController.getNotificationsByTeamId);
|
||||
router.get("/:id", notificationController.getNotificationById);
|
||||
router.delete("/:id", notificationController.deleteNotification);
|
||||
router.patch("/:id", notificationController.editNotification);
|
||||
router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
|
||||
router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
|
||||
return router;
|
||||
};
|
||||
@@ -23,6 +23,8 @@ export const registrationBodyValidation = z.object({
|
||||
inviteToken: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export const registerInviteTokenValidation = z.string().optional().default("");
|
||||
|
||||
export const recoveryValidation = z.object({
|
||||
email: z.email("Must be a valid email address"),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
ProxyModes,
|
||||
} from "@/domain/monitors/monitor.type.js";
|
||||
import { DateRanges, SortOrders } from "@/types/query.js";
|
||||
import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.js";
|
||||
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
|
||||
import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
|
||||
import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
|
||||
|
||||
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
|
||||
|
||||
@@ -119,23 +123,57 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
|
||||
}
|
||||
};
|
||||
|
||||
const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/;
|
||||
|
||||
const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => {
|
||||
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
|
||||
if (data.type !== "docker" || data.url === undefined) return;
|
||||
if (!dockerUrlRegex.test(data.url)) {
|
||||
if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["url"],
|
||||
message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path",
|
||||
message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]",
|
||||
});
|
||||
}
|
||||
if (data.url.startsWith("ssh://") && !data.sshPrivateKey) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["sshPrivateKey"],
|
||||
message: "SSH Docker hosts require a private key",
|
||||
});
|
||||
};
|
||||
|
||||
type DockerTlsFields = {
|
||||
type?: string;
|
||||
url?: string;
|
||||
ignoreTlsErrors?: boolean;
|
||||
dockerTlsCa?: string;
|
||||
dockerTlsCert?: string;
|
||||
dockerTlsKey?: string;
|
||||
};
|
||||
|
||||
const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => {
|
||||
if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return;
|
||||
const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
|
||||
|
||||
if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host");
|
||||
|
||||
if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host");
|
||||
|
||||
if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored");
|
||||
|
||||
let certificate: X509Certificate | undefined;
|
||||
|
||||
try {
|
||||
if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa);
|
||||
} catch (error: unknown) {
|
||||
issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error");
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert);
|
||||
} catch (error) {
|
||||
issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error");
|
||||
}
|
||||
|
||||
try {
|
||||
if (data.dockerTlsKey) {
|
||||
const key = parsePrivateKey(data.dockerTlsKey);
|
||||
if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,7 +201,6 @@ export const createMonitorBodyValidation = z
|
||||
tags: z.array(z.string()).optional(),
|
||||
customUpCodes: z.array(httpStatusCode).default([]),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
@@ -176,6 +213,10 @@ export const createMonitorBodyValidation = z
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dockerLogsEnabled: z.boolean().optional(),
|
||||
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
||||
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
||||
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
@@ -184,7 +225,8 @@ export const createMonitorBodyValidation = z
|
||||
.superRefine(refineHeadMatching)
|
||||
.superRefine(refineRegexPattern)
|
||||
.superRefine(refineProxySelection)
|
||||
.superRefine(refineDockerUrl);
|
||||
.superRefine(refineDockerUrl)
|
||||
.superRefine(refineDockerTls("create"));
|
||||
|
||||
export const editMonitorBodyValidation = z
|
||||
.object({
|
||||
@@ -199,7 +241,6 @@ export const editMonitorBodyValidation = z
|
||||
tags: z.array(z.string()).optional(),
|
||||
customUpCodes: z.array(httpStatusCode).optional(),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean().optional(),
|
||||
proxyMode: z.enum(ProxyModes).optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
@@ -221,6 +262,10 @@ export const editMonitorBodyValidation = z
|
||||
geoCheckEnabled: z.boolean().optional(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||
geoCheckInterval: z.number().min(300000).optional(),
|
||||
dockerLogsEnabled: z.boolean().optional(),
|
||||
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
||||
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
||||
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
@@ -229,7 +274,8 @@ export const editMonitorBodyValidation = z
|
||||
.superRefine(refineHeadMatching)
|
||||
.superRefine(refineRegexPattern)
|
||||
.superRefine(refineProxySelection)
|
||||
.superRefine(refineDockerUrl);
|
||||
.superRefine(refineDockerUrl)
|
||||
.superRefine(refineDockerTls("edit"));
|
||||
|
||||
export const pauseMonitorParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
@@ -280,7 +326,6 @@ const importedMonitorSchema = z
|
||||
tags: z.array(z.string()).default([]),
|
||||
customUpCodes: z.array(httpStatusCode).default([]),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
cpuAlertThreshold: z.number().default(100),
|
||||
cpuAlertCounter: z.number().default(5),
|
||||
memoryAlertThreshold: z.number().default(100),
|
||||
@@ -297,6 +342,7 @@ const importedMonitorSchema = z
|
||||
geoCheckEnabled: z.boolean().default(false),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||
geoCheckInterval: z.number().min(300000).default(300000),
|
||||
dockerLogsEnabled: z.boolean().default(false),
|
||||
dnsServer: dnsServerValidation.optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
createdAt: z.string().optional(),
|
||||
@@ -332,6 +378,21 @@ export const getDockerContainerNameParamValidation = z.object({
|
||||
});
|
||||
export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
|
||||
|
||||
export const getDockerContainerLogsQueryValidation = z
|
||||
.object({
|
||||
before: z.iso.datetime().optional(),
|
||||
after: z.iso.datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT),
|
||||
})
|
||||
.superRefine((query, ctx) => {
|
||||
if (query.before && query.after) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Specify either before or after, not both",
|
||||
path: ["after"],
|
||||
});
|
||||
}
|
||||
});
|
||||
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
|
||||
// what the controllers actually serialize.
|
||||
export const monitorResponseSchema = z
|
||||
@@ -359,7 +420,6 @@ export const monitorResponseSchema = z
|
||||
tags: z.array(z.string()),
|
||||
customUpCodes: z.array(httpStatusCode).optional(),
|
||||
secret: z.string().optional(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
cpuAlertThreshold: z.number(),
|
||||
memoryAlertThreshold: z.number(),
|
||||
diskAlertThreshold: z.number(),
|
||||
@@ -371,6 +431,10 @@ export const monitorResponseSchema = z
|
||||
geoCheckEnabled: z.boolean(),
|
||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||
geoCheckInterval: z.number(),
|
||||
dockerLogsEnabled: z.boolean(),
|
||||
dockerTlsCa: z.string().optional(),
|
||||
dockerTlsCert: z.string().optional(),
|
||||
dockerTlsKeySet: z.boolean().optional(),
|
||||
dnsServer: z.string().optional(),
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
teamId: z.string(),
|
||||
@@ -529,3 +593,26 @@ export const dockerContainerDetailsResponseSchema = z.object({
|
||||
.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const dockerLogLineResponseSchema = z.object({
|
||||
ts: z.string(),
|
||||
stream: z.enum(DockerLogStreams),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const dockerLogResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
metadata: z.object({ monitorId: z.string(), teamId: z.string(), containerId: z.string(), containerName: z.string() }),
|
||||
lines: z.array(dockerLogLineResponseSchema),
|
||||
gap: z.boolean(),
|
||||
checkedAt: z.string(),
|
||||
expiry: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}/logs.
|
||||
export const dockerContainerLogsResponseSchema = z.object({
|
||||
logs: z.array(dockerLogResponseSchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
});
|
||||
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
address: z.string().min(1, "User key is required"),
|
||||
accessToken: z.string().min(1, "App token is required"),
|
||||
}),
|
||||
// Signalgrid notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("signalgrid"),
|
||||
address: z.string().min(1, "Channel is required"),
|
||||
accessToken: z.string().min(1, "Client key is required"),
|
||||
}),
|
||||
// Twilio SMS notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
|
||||
@@ -3,6 +3,39 @@ import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settin
|
||||
import { booleanCoercion } from "@/api/validation/shared.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
// Padded standard base64 of exactly 32 bytes, as produced by `openssl rand -base64 32`
|
||||
const ENCRYPTION_KEY_ENTRY = /^[A-Za-z0-9+/]{43}=$/;
|
||||
|
||||
export const encryptionKeyList = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((raw, ctx) => {
|
||||
if (raw === undefined) return [];
|
||||
const entries = raw
|
||||
.split(",")
|
||||
.map((entry, index) => ({ entry: entry.trim(), slot: index + 1 }))
|
||||
.filter(({ entry }) => entry.length > 0);
|
||||
const seen = new Set<string>();
|
||||
const keys: Buffer[] = [];
|
||||
entries.forEach(({ entry, slot }) => {
|
||||
if (!ENCRYPTION_KEY_ENTRY.test(entry)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `ENCRYPTION_KEY entry ${slot} must be 32 bytes as padded standard base64 (openssl rand -base64 32)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const decoded = Buffer.from(entry, "base64");
|
||||
const fingerprint = decoded.toString("hex");
|
||||
if (seen.has(fingerprint)) {
|
||||
ctx.addIssue({ code: "custom", message: `ENCRYPTION_KEY entry ${slot} duplicates an earlier entry` });
|
||||
}
|
||||
seen.add(fingerprint);
|
||||
keys.push(decoded);
|
||||
});
|
||||
return keys;
|
||||
});
|
||||
|
||||
const envSchema = z.object({
|
||||
// Server Configuration
|
||||
PORT: z.string().default("52345"),
|
||||
@@ -36,6 +69,9 @@ const envSchema = z.object({
|
||||
|
||||
// Feature flags
|
||||
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
|
||||
|
||||
// Encryption
|
||||
ENCRYPTION_KEY: encryptionKeyList,
|
||||
});
|
||||
|
||||
export type ValidatedEnv = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -35,6 +35,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
usersRepository,
|
||||
@@ -75,9 +76,11 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
encryptionService: shared.encryptionService,
|
||||
});
|
||||
|
||||
const maintenanceWindowService = new MaintenanceWindowService({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Mongoose } from "mongoose";
|
||||
import { hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as nodeCrypto from "node:crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import nodemailer from "nodemailer";
|
||||
@@ -13,12 +13,14 @@ import { IDb } from "@/db/db.interface.js";
|
||||
import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { ICheckService, CheckService } from "@/domain/checks/check.service.js";
|
||||
import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
|
||||
import { IDockerLogsService, DockerLogsService } from "@/domain/docker/docker-log.service.js";
|
||||
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
|
||||
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
|
||||
import { IEmailService, EmailService } from "@/service/emailService.js";
|
||||
import { GlobalPingService } from "@/service/globalPingService.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js";
|
||||
|
||||
// Notification providers
|
||||
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
|
||||
@@ -32,6 +34,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
|
||||
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
|
||||
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
|
||||
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
|
||||
import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
|
||||
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
|
||||
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
|
||||
|
||||
@@ -58,6 +61,7 @@ import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.
|
||||
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
|
||||
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
|
||||
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
|
||||
import MongoDockerLogsRepository from "@/domain/docker/docker-log.repository.mongo.js";
|
||||
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
|
||||
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
|
||||
import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js";
|
||||
@@ -72,6 +76,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
|
||||
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
|
||||
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
|
||||
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
|
||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||
|
||||
// Shared infrastructure + business services that both the API and the worker process construct.
|
||||
export interface SharedServices {
|
||||
@@ -79,10 +84,12 @@ export interface SharedServices {
|
||||
db: IDb<Mongoose>;
|
||||
settingsService: ISettingsService;
|
||||
emailService: IEmailService;
|
||||
encryptionService: IEncryptionService;
|
||||
notificationMessageBuilder: INotificationMessageBuilder;
|
||||
incidentService: IIncidentService;
|
||||
checkService: ICheckService;
|
||||
geoChecksService: IGeoChecksService;
|
||||
dockerLogsService: IDockerLogsService;
|
||||
notificationsService: INotificationsService;
|
||||
|
||||
// Queue identity (one per process; the worker reuses these)
|
||||
@@ -94,6 +101,7 @@ export interface SharedServices {
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
checksRepository: IChecksRepository;
|
||||
geoChecksRepository: IGeoChecksRepository;
|
||||
dockerLogsRepository: IDockerLogsRepository;
|
||||
monitorStatsRepository: IMonitorStatsRepository;
|
||||
statusPagesRepository: IStatusPagesRepository;
|
||||
usersRepository: IUsersRepository;
|
||||
@@ -112,13 +120,18 @@ export const buildShared = async ({
|
||||
logger,
|
||||
envSettings,
|
||||
settingsService,
|
||||
encryptionKeys,
|
||||
}: {
|
||||
logger: ILogger;
|
||||
envSettings: EnvConfig;
|
||||
settingsService: ISettingsService;
|
||||
encryptionKeys: Buffer[];
|
||||
}): Promise<SharedServices> => {
|
||||
// Shared worker ID
|
||||
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
|
||||
const workerId = `${hostname()}:${process.pid}:${nodeCrypto.randomUUID()}`;
|
||||
|
||||
// Create the encryption service
|
||||
const encryptionService = new EncryptionService(encryptionKeys, logger, nodeCrypto);
|
||||
|
||||
// Create DB
|
||||
let db: IDb<Mongoose> | null = null;
|
||||
@@ -131,6 +144,7 @@ export const buildShared = async ({
|
||||
const incidentsRepository = new MongoIncidentsRepository();
|
||||
const usersRepository = new MongoUsersRepository();
|
||||
const geoChecksRepository = new MongoGeoChecksRepository();
|
||||
const dockerLogsRepository = new MongoDockerLogsRepository();
|
||||
const notificationsRepository = new MongoNotificationsRepository();
|
||||
const jobsRepository = new MongoJobsRepository(workerId);
|
||||
const queueWorkersRepository = new MongoQueueWorkersRepository();
|
||||
@@ -161,6 +175,7 @@ export const buildShared = async ({
|
||||
monitorsRepository,
|
||||
});
|
||||
|
||||
const dockerLogsService = new DockerLogsService({ logger, dockerLogsRepository });
|
||||
const webhookProvider = new WebhookProvider(logger);
|
||||
const rocketChatProvider = new RocketChatProvider(logger);
|
||||
const slackProvider = new SlackProvider(logger);
|
||||
@@ -171,6 +186,7 @@ export const buildShared = async ({
|
||||
const teamsProvider = new TeamsProvider(logger);
|
||||
const telegramProvider = new TelegramProvider(logger);
|
||||
const pushoverProvider = new PushoverProvider(logger);
|
||||
const signalgridProvider = new SignalgridProvider(logger);
|
||||
const twilioProvider = new TwilioProvider(logger);
|
||||
const ntfyProvider = new NtfyProvider(logger);
|
||||
|
||||
@@ -185,6 +201,7 @@ export const buildShared = async ({
|
||||
teams: teamsProvider,
|
||||
telegram: telegramProvider,
|
||||
pushover: pushoverProvider,
|
||||
signalgrid: signalgridProvider,
|
||||
twilio: twilioProvider,
|
||||
ntfy: ntfyProvider,
|
||||
};
|
||||
@@ -203,10 +220,12 @@ export const buildShared = async ({
|
||||
db,
|
||||
settingsService,
|
||||
emailService,
|
||||
encryptionService,
|
||||
notificationMessageBuilder,
|
||||
incidentService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
dockerLogsService,
|
||||
notificationsService,
|
||||
workerId,
|
||||
jobsRepository,
|
||||
@@ -214,6 +233,7 @@ export const buildShared = async ({
|
||||
queueWorkersRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
usersRepository,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SharedServices } from "@/config/services.shared.js";
|
||||
import { INetworkService, NetworkService } from "@/service/networkService.js";
|
||||
import { IBufferService, BufferService } from "@/service/bufferService.js";
|
||||
import { IStatusService, StatusService } from "@/service/statusService.js";
|
||||
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
|
||||
import { IQueueWorker } from "@/worker/worker.interface.js";
|
||||
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
|
||||
import { WorkerHelper } from "@/worker/worker.helper.js";
|
||||
@@ -39,6 +40,7 @@ import { GameProvider } from "@/service/network/GameProvider.js";
|
||||
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
|
||||
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
|
||||
import { DNSProvider } from "@/service/network/DNSProvider.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
export interface WorkerServices {
|
||||
worker: IQueueWorker;
|
||||
networkService: INetworkService;
|
||||
@@ -52,14 +54,17 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
settingsService,
|
||||
checkService,
|
||||
geoChecksService,
|
||||
dockerLogsService,
|
||||
notificationsService,
|
||||
incidentService,
|
||||
workerId,
|
||||
jobsRepository,
|
||||
queueWorkersRepository,
|
||||
monitorsRepository,
|
||||
encryptionService,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
incidentsRepository,
|
||||
teamsRepository,
|
||||
@@ -74,7 +79,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
|
||||
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
|
||||
const hardwareProvider = new HardwareProvider(httpProvider);
|
||||
const dockerProvider = new DockerProvider(logger, Docker);
|
||||
const dockerProvider = new DockerProvider(logger, Docker, encryptionService);
|
||||
const portProvider = new PortProvider(net);
|
||||
const gameProvider = new GameProvider(logger, GameDig);
|
||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||
@@ -95,7 +100,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
]);
|
||||
|
||||
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
|
||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||
const monitorStatusPolicy = new MonitorStatusPolicy();
|
||||
|
||||
@@ -119,6 +124,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
networkService,
|
||||
proxyResolver,
|
||||
bufferService,
|
||||
dockerLogsService,
|
||||
logger
|
||||
);
|
||||
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
|
||||
@@ -138,7 +144,8 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
monitorStatsRepository,
|
||||
checksRepository,
|
||||
incidentsRepository,
|
||||
geoChecksRepository
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository
|
||||
);
|
||||
|
||||
const worker = await DBQueueWorker.create({
|
||||
@@ -160,5 +167,23 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
workerId,
|
||||
});
|
||||
|
||||
const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger);
|
||||
try {
|
||||
const result = await secretsRotationService.run();
|
||||
logger.debug({
|
||||
message: "Secrets rotation run successfully",
|
||||
service: "WorkerServices",
|
||||
method: "buildWorker",
|
||||
details: { ...result },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.warn({
|
||||
message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: "WorkerServices",
|
||||
method: "buildWorker",
|
||||
details: { ...(error instanceof AppError ? error.details : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
return { worker, networkService, bufferService, statusService };
|
||||
};
|
||||
@@ -7,8 +7,8 @@ import type { ILogger } from "@/utils/logger.js";
|
||||
* The old docker provider interpreted monitor.url as a container name/ID and only
|
||||
* ever talked to the local socket, so every pre-existing docker monitor's host is
|
||||
* definitionally unix:///var/run/docker.sock. The container name is preserved in
|
||||
* the description. Urls already in host form (unix://, ssh://, or an absolute
|
||||
* socket path) are left untouched, which makes the migration idempotent.
|
||||
* the description. Urls already in host form (unix:// or an absolute socket
|
||||
* path) are left untouched, which makes the migration idempotent.
|
||||
*/
|
||||
export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
|
||||
const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls";
|
||||
@@ -16,7 +16,7 @@ export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
|
||||
try {
|
||||
logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" });
|
||||
|
||||
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [
|
||||
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [
|
||||
{
|
||||
$set: {
|
||||
description: {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
DockerContainerSummary,
|
||||
DockerHealthStatuses,
|
||||
DockerPortProtocols,
|
||||
} from "@/types/network.js";
|
||||
} from "@/domain/docker/docker.type.js";
|
||||
|
||||
type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
SnapshotHostInfo,
|
||||
SnapshotMemoryInfo,
|
||||
} from "@/domain/checks/check.type.js";
|
||||
import { DockerContainerSummary } from "@/types/network.js";
|
||||
import { DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
||||
import { toDateString } from "@/utils/mongoMappers.js";
|
||||
|
||||
export type CheckSnapshotSource = Pick<
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js";
|
||||
import { DockerContainerInfo, DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
||||
import type { Response } from "got";
|
||||
|
||||
export const CHECK_TTL_SENTINEL = 366;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Schema, model, Types } from "mongoose";
|
||||
import { DockerLogStreams, type DockerLogLine } from "@/domain/docker/docker.type.js";
|
||||
import type { DockerLog, DockerLogMetadata } from "@/domain/docker/docker-log.type.js";
|
||||
|
||||
type DockerLogMetadataDocument = Omit<DockerLogMetadata, "monitorId" | "teamId"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
teamId: Types.ObjectId;
|
||||
};
|
||||
|
||||
type DockerLogDocumentBase = Omit<DockerLog, "id" | "metadata" | "checkedAt" | "expiry" | "createdAt" | "updatedAt"> & {
|
||||
metadata: DockerLogMetadataDocument;
|
||||
checkedAt: Date;
|
||||
expiry: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface DockerLogDocument extends DockerLogDocumentBase {
|
||||
_id: Types.ObjectId;
|
||||
}
|
||||
|
||||
const dockerLogMetadataSchema = new Schema<DockerLogMetadataDocument>(
|
||||
{
|
||||
monitorId: { type: Schema.Types.ObjectId, required: true },
|
||||
teamId: { type: Schema.Types.ObjectId, required: true },
|
||||
containerId: { type: String, required: true },
|
||||
containerName: { type: String, required: true },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const dockerLogLineSchema = new Schema<DockerLogLine>(
|
||||
{
|
||||
ts: { type: String, required: true },
|
||||
stream: { type: String, enum: DockerLogStreams, required: true },
|
||||
text: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const DockerLogSchema = new Schema<DockerLogDocument>(
|
||||
{
|
||||
metadata: { type: dockerLogMetadataSchema, required: true },
|
||||
lines: { type: [dockerLogLineSchema], required: true, default: [] },
|
||||
gap: { type: Boolean, default: false },
|
||||
checkedAt: { type: Date, required: true },
|
||||
expiry: { type: Date, required: true, index: { expires: 0 } }, // TTL: Mongo deletes the doc once `expiry` passes
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerName": 1, checkedAt: -1 }); // API paging
|
||||
DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerId": 1, checkedAt: -1 }); // cursor seed
|
||||
DockerLogSchema.index({ "metadata.teamId": 1 });
|
||||
|
||||
const DockerLogModel = model<DockerLogDocument>("DockerLog", DockerLogSchema);
|
||||
|
||||
export type { DockerLogMetadataDocument };
|
||||
export { DockerLogModel };
|
||||
export default DockerLogModel;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DockerLog } from "@/domain/docker/docker-log.type.js";
|
||||
|
||||
export interface IDockerLogsRepository {
|
||||
createDockerLogs(logs: Omit<DockerLog, "id" | "createdAt" | "updatedAt">[]): Promise<number>;
|
||||
findLastLineTimestamp(monitorId: string, containerId: string): Promise<string | null>;
|
||||
findByContainerName(args: { monitorId: string; containerName: string; before?: Date; after?: Date; limit: number }): Promise<DockerLog[]>;
|
||||
deleteByMonitorId(monitorId: string): Promise<number>;
|
||||
deleteByTeamId(teamId: string): Promise<number>;
|
||||
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import DockerLogModel, { DockerLogDocument } from "@/domain/docker/docker-log.model.js";
|
||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||
import { DockerLog } from "@/domain/docker/docker-log.type.js";
|
||||
import { toDateString, toStringId } from "@/utils/mongoMappers.js";
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const SERVICE_NAME = "DockerLogsRepository";
|
||||
|
||||
class MongoDockerLogsRepository implements IDockerLogsRepository {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private toEntity = (doc: DockerLogDocument): DockerLog => {
|
||||
return {
|
||||
id: toStringId(doc._id),
|
||||
metadata: {
|
||||
monitorId: toStringId(doc.metadata.monitorId),
|
||||
teamId: toStringId(doc.metadata.teamId),
|
||||
containerId: doc.metadata.containerId,
|
||||
containerName: doc.metadata.containerName,
|
||||
},
|
||||
lines: (doc.lines ?? []).map((line) => ({ ts: line.ts, stream: line.stream, text: line.text ?? "" })),
|
||||
gap: doc.gap ?? false,
|
||||
checkedAt: toDateString(doc.checkedAt),
|
||||
expiry: toDateString(doc.expiry),
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
};
|
||||
|
||||
createDockerLogs = async (logs: Omit<DockerLog, "id" | "createdAt" | "updatedAt">[]): Promise<number> => {
|
||||
if (logs.length === 0) return 0;
|
||||
const docs = await DockerLogModel.insertMany(
|
||||
logs.map((log) => {
|
||||
return {
|
||||
metadata: {
|
||||
monitorId: new mongoose.Types.ObjectId(log.metadata.monitorId),
|
||||
teamId: new mongoose.Types.ObjectId(log.metadata.teamId),
|
||||
containerId: log.metadata.containerId,
|
||||
containerName: log.metadata.containerName,
|
||||
},
|
||||
lines: log.lines,
|
||||
gap: log.gap,
|
||||
checkedAt: new Date(log.checkedAt),
|
||||
expiry: new Date(log.expiry),
|
||||
};
|
||||
}),
|
||||
{ ordered: false }
|
||||
);
|
||||
return docs.length;
|
||||
};
|
||||
|
||||
findLastLineTimestamp = async (monitorId: string, containerId: string): Promise<string | null> => {
|
||||
const doc = await DockerLogModel.findOne({
|
||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||
"metadata.containerId": containerId,
|
||||
})
|
||||
.sort({ checkedAt: -1 })
|
||||
.select({ lines: { $slice: -1 } })
|
||||
.lean<Pick<DockerLogDocument, "lines">>();
|
||||
return doc?.lines?.[0]?.ts ?? null;
|
||||
};
|
||||
|
||||
findByContainerName = async ({
|
||||
monitorId,
|
||||
containerName,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
}: {
|
||||
monitorId: string;
|
||||
containerName: string;
|
||||
before?: Date;
|
||||
after?: Date;
|
||||
limit: number;
|
||||
}): Promise<DockerLog[]> => {
|
||||
const docs = await DockerLogModel.find({
|
||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||
"metadata.containerName": containerName,
|
||||
...(before ? { checkedAt: { $lt: before } } : {}),
|
||||
...(after ? { checkedAt: { $gt: after } } : {}),
|
||||
})
|
||||
.sort({ checkedAt: after ? 1 : -1 })
|
||||
.limit(limit)
|
||||
.lean<DockerLogDocument[]>();
|
||||
const entities = docs.map(this.toEntity);
|
||||
return after ? entities.reverse() : entities;
|
||||
};
|
||||
|
||||
deleteByMonitorId = async (monitorId: string) =>
|
||||
(await DockerLogModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) })).deletedCount ?? 0;
|
||||
|
||||
deleteByTeamId = async (teamId: string) =>
|
||||
(await DockerLogModel.deleteMany({ "metadata.teamId": new mongoose.Types.ObjectId(teamId) })).deletedCount ?? 0;
|
||||
|
||||
deleteByMonitorIdsNotIn = async (monitorIds: string[]) =>
|
||||
(await DockerLogModel.deleteMany({ "metadata.monitorId": { $nin: monitorIds.map((id) => new mongoose.Types.ObjectId(id)) } })).deletedCount ?? 0;
|
||||
}
|
||||
|
||||
export default MongoDockerLogsRepository;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||
import { DOCKER_LOG_RETENTION_DAYS, DockerLog } from "@/domain/docker/docker-log.type.js";
|
||||
import { DOCKER_LOG_TAIL_LINES, DockerLogLine } from "@/domain/docker/docker.type.js";
|
||||
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const SERVICE_NAME = "DockerLogsService";
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface IDockerLogsService {
|
||||
buildDockerLogs(status: MonitorStatusResponse<DockerStatusPayload>): Promise<DockerLog[]>;
|
||||
createDockerLogs(logs: DockerLog[]): Promise<number>;
|
||||
}
|
||||
|
||||
export class DockerLogsService implements IDockerLogsService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private logger: ILogger;
|
||||
private dockerLogsRepository: IDockerLogsRepository;
|
||||
|
||||
private lastTimestampByContainer = new Map<string, string>();
|
||||
|
||||
constructor({ logger, dockerLogsRepository }: { logger: ILogger; dockerLogsRepository: IDockerLogsRepository }) {
|
||||
this.logger = logger;
|
||||
this.dockerLogsRepository = dockerLogsRepository;
|
||||
}
|
||||
|
||||
private generateCursorKey = (monitorId: string, containerId: string) => `${monitorId}:${containerId}`;
|
||||
|
||||
private getLastTimestamp = async (monitorId: string, containerId: string) => {
|
||||
const key = this.generateCursorKey(monitorId, containerId);
|
||||
const cached = this.lastTimestampByContainer.get(key);
|
||||
if (cached !== undefined) return cached || null;
|
||||
const stored = await this.dockerLogsRepository.findLastLineTimestamp(monitorId, containerId);
|
||||
this.lastTimestampByContainer.set(key, stored ?? "");
|
||||
return stored;
|
||||
};
|
||||
|
||||
private newLines = (lines: DockerLogLine[], lastTimestamp: string | null): DockerLogLine[] => {
|
||||
return lastTimestamp
|
||||
? lines.filter((line) => {
|
||||
return line.ts > lastTimestamp;
|
||||
})
|
||||
: lines;
|
||||
};
|
||||
|
||||
private maxTimestamp = (lines: DockerLogLine[]) => {
|
||||
return lines.reduce((max, line) => (line.ts > max ? line.ts : max), "");
|
||||
};
|
||||
|
||||
buildDockerLogs = async (status: MonitorStatusResponse<DockerStatusPayload>): Promise<DockerLog[]> => {
|
||||
const payload = typeof status.payload === "object" ? status.payload : null;
|
||||
const containerLogs = payload?.logs ?? [];
|
||||
if (containerLogs.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
const checkedAt = now.toISOString();
|
||||
const expiry = new Date(now.getTime() + DOCKER_LOG_RETENTION_DAYS * MS_PER_DAY).toISOString();
|
||||
const results: DockerLog[] = [];
|
||||
|
||||
for (const dockerContainerLog of containerLogs) {
|
||||
const { containerId, containerName, lines } = dockerContainerLog;
|
||||
try {
|
||||
const lastTimestamp = await this.getLastTimestamp(status.monitorId, containerId);
|
||||
const fresh = this.newLines(lines, lastTimestamp);
|
||||
if (fresh.length === 0) continue;
|
||||
this.lastTimestampByContainer.set(this.generateCursorKey(status.monitorId, containerId), this.maxTimestamp(fresh));
|
||||
results.push({
|
||||
id: new mongoose.Types.ObjectId().toString(),
|
||||
metadata: {
|
||||
monitorId: status.monitorId,
|
||||
teamId: status.teamId,
|
||||
containerId,
|
||||
containerName,
|
||||
},
|
||||
lines: fresh,
|
||||
gap: fresh.length === DOCKER_LOG_TAIL_LINES,
|
||||
// We only fetch DOCKER_LOG_TAIL_LINES worth of logs.
|
||||
// If we have 200 fresh logs, there _could_ be more than 200 lines, so set gap = true
|
||||
// Client can display that some logs may be missing
|
||||
checkedAt,
|
||||
expiry,
|
||||
createdAt: checkedAt,
|
||||
updatedAt: checkedAt,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: `Failed to build docker logs for container ${containerName}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "buildDockerLogs",
|
||||
details: { monitorId: status.monitorId, containerId, error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
createDockerLogs = async (logs: DockerLog[]): Promise<number> => this.dockerLogsRepository.createDockerLogs(logs);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { DockerLogLine } from "@/domain/docker/docker.type.js";
|
||||
|
||||
export const DOCKER_LOG_RETENTION_DAYS = 7;
|
||||
export const DOCKER_LOG_PAGE_DEFAULT = 20;
|
||||
export const DOCKER_LOG_PAGE_MAX = 50;
|
||||
|
||||
export interface DockerLogMetadata {
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
export interface DockerLog {
|
||||
id: string;
|
||||
metadata: DockerLogMetadata;
|
||||
lines: DockerLogLine[];
|
||||
gap: boolean;
|
||||
checkedAt: string;
|
||||
expiry: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DockerLogPage {
|
||||
logs: DockerLog[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const;
|
||||
export type DockerContainerState = (typeof DockerContainerStates)[number];
|
||||
|
||||
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
|
||||
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
|
||||
|
||||
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
|
||||
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
|
||||
|
||||
export const DockerLogStreams = ["stdout", "stderr"] as const;
|
||||
export type DockerLogStream = (typeof DockerLogStreams)[number];
|
||||
|
||||
export const DOCKER_LOG_TAIL_LINES = 200;
|
||||
|
||||
export interface DockerLogLine {
|
||||
ts: string;
|
||||
stream: DockerLogStream;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DockerContainerLogs {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
lines: DockerLogLine[];
|
||||
}
|
||||
|
||||
export interface DockerContainerPort {
|
||||
privatePort: number;
|
||||
protocol: DockerPortProtocol;
|
||||
publicPort?: number;
|
||||
hostIp?: string;
|
||||
}
|
||||
|
||||
export interface DockerContainerMount {
|
||||
type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, …
|
||||
name?: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
rw: boolean;
|
||||
}
|
||||
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: DockerContainerState;
|
||||
status: string;
|
||||
health: DockerHealthStatus;
|
||||
cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores
|
||||
memoryUsedBytes?: number;
|
||||
memoryLimitBytes?: number;
|
||||
memoryPct?: number; // 0-1 fraction of the memory limit
|
||||
restartCount?: number;
|
||||
startedAt?: string; // ISO date
|
||||
ports?: DockerContainerPort[];
|
||||
mounts?: DockerContainerMount[];
|
||||
}
|
||||
|
||||
export interface DockerContainerSummary {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
unhealthy: number;
|
||||
}
|
||||
@@ -235,9 +235,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
secret: {
|
||||
type: String,
|
||||
},
|
||||
sshPrivateKey: {
|
||||
type: String,
|
||||
},
|
||||
cpuAlertThreshold: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
@@ -306,6 +303,24 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
type: Number,
|
||||
default: 300000,
|
||||
},
|
||||
dockerLogsEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dockerTlsCa: {
|
||||
type: String,
|
||||
},
|
||||
dockerTlsCert: {
|
||||
type: String,
|
||||
},
|
||||
dockerTlsKey: {
|
||||
type: String,
|
||||
select: false,
|
||||
},
|
||||
dockerTlsKeySet: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dnsServer: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface IMonitorsRepository {
|
||||
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
|
||||
findMonitorCountByProxyId(proxyId: string): Promise<number>;
|
||||
|
||||
// Docker TLS
|
||||
findDockerTlsKeyById(monitorId: string): Promise<string | null>;
|
||||
findAllDockerTlsKeys(): Promise<{ id: string; dockerTlsKey: string }[]>;
|
||||
updateDockerTlsKey(monitorId: string, dockerTlsKey: string): Promise<void>; // Needed for worker to rotate keys, no teamId/userId
|
||||
|
||||
// other
|
||||
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
|
||||
removeNotificationFromMonitors(notificationId: string): Promise<void>;
|
||||
|
||||
@@ -298,6 +298,20 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return { monitors: this.mapDocuments(monitors), deletedCount };
|
||||
};
|
||||
|
||||
findDockerTlsKeyById = async (monitorId: string): Promise<string | null> => {
|
||||
const doc = await MonitorModel.findOne({ _id: monitorId }, { dockerTlsKey: 1 }).lean();
|
||||
return doc?.dockerTlsKey ?? null;
|
||||
};
|
||||
|
||||
findAllDockerTlsKeys = async (): Promise<{ id: string; dockerTlsKey: string }[]> => {
|
||||
const docs = await MonitorModel.find({ dockerTlsKeySet: true }, { dockerTlsKey: 1 }).lean();
|
||||
return docs.flatMap((doc) => (doc.dockerTlsKey ? [{ id: doc._id.toString(), dockerTlsKey: doc.dockerTlsKey }] : []));
|
||||
};
|
||||
|
||||
updateDockerTlsKey = async (monitorId: string, dockerTlsKey: string): Promise<void> => {
|
||||
await MonitorModel.updateOne({ _id: monitorId }, { $set: { dockerTlsKey } });
|
||||
};
|
||||
|
||||
findMonitorsSummaryByTeamId = async (teamId: string, config: SummaryConfig): Promise<MonitorsSummary> => {
|
||||
const match = this.queryBuilder(config, teamId);
|
||||
const pipeline = [
|
||||
@@ -436,7 +450,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
tags: tagIds,
|
||||
customUpCodes: doc.customUpCodes ?? [],
|
||||
secret: doc.secret ?? undefined,
|
||||
sshPrivateKey: doc.sshPrivateKey ?? undefined,
|
||||
cpuAlertThreshold: doc.cpuAlertThreshold,
|
||||
cpuAlertCounter: doc.cpuAlertCounter,
|
||||
memoryAlertThreshold: doc.memoryAlertThreshold,
|
||||
@@ -454,6 +467,10 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
geoCheckEnabled: doc.geoCheckEnabled ?? false,
|
||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
|
||||
dockerTlsCa: doc.dockerTlsCa ?? undefined,
|
||||
dockerTlsCert: doc.dockerTlsCert ?? undefined,
|
||||
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
|
||||
dnsServer: doc.dnsServer ?? undefined,
|
||||
dnsRecordType: doc.dnsRecordType ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GroupedGeoCheckResult,
|
||||
DockerDetailsResult,
|
||||
DockerContainerDetailsResult,
|
||||
DockerContainerLogsResult,
|
||||
} from "@/domain/monitors/monitor.type.js";
|
||||
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js";
|
||||
import type {
|
||||
@@ -31,6 +32,9 @@ import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IJobScheduler } from "@/worker/worker.interface.js";
|
||||
import { DateRange } from "@/types/query.js";
|
||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
||||
import { isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||
|
||||
const SERVICE_NAME = "MonitorService";
|
||||
|
||||
@@ -58,7 +62,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number
|
||||
|
||||
export interface IMonitorService {
|
||||
// create
|
||||
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
|
||||
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<Monitor>;
|
||||
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
|
||||
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
|
||||
|
||||
@@ -73,6 +77,14 @@ export interface IMonitorService {
|
||||
containerName: string;
|
||||
dateRange: DateRange;
|
||||
}): Promise<DockerContainerDetailsResult>;
|
||||
getDockerContainerLogs(args: {
|
||||
teamId: string;
|
||||
monitorId: string;
|
||||
containerName: string;
|
||||
before?: Date;
|
||||
after?: Date;
|
||||
limit: number;
|
||||
}): Promise<DockerContainerLogsResult>;
|
||||
getGeoChecksByMonitorId(args: {
|
||||
teamId: string;
|
||||
monitorId: string;
|
||||
@@ -130,9 +142,11 @@ export class MonitorService implements IMonitorService {
|
||||
private monitorsRepository: IMonitorsRepository;
|
||||
private checksRepository: IChecksRepository;
|
||||
private geoChecksRepository: IGeoChecksRepository;
|
||||
private dockerLogsRepository: IDockerLogsRepository;
|
||||
private monitorStatsRepository: IMonitorStatsRepository;
|
||||
private statusPagesRepository: IStatusPagesRepository;
|
||||
private incidentsRepository: IIncidentsRepository;
|
||||
private encryptionService: IEncryptionService;
|
||||
|
||||
constructor({
|
||||
scheduler,
|
||||
@@ -141,9 +155,11 @@ export class MonitorService implements IMonitorService {
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
dockerLogsRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
encryptionService,
|
||||
}: {
|
||||
scheduler: IJobScheduler;
|
||||
logger: ILogger;
|
||||
@@ -151,9 +167,11 @@ export class MonitorService implements IMonitorService {
|
||||
monitorsRepository: IMonitorsRepository;
|
||||
checksRepository: IChecksRepository;
|
||||
geoChecksRepository: IGeoChecksRepository;
|
||||
dockerLogsRepository: IDockerLogsRepository;
|
||||
monitorStatsRepository: IMonitorStatsRepository;
|
||||
statusPagesRepository: IStatusPagesRepository;
|
||||
incidentsRepository: IIncidentsRepository;
|
||||
encryptionService: IEncryptionService;
|
||||
}) {
|
||||
this.scheduler = scheduler;
|
||||
this.logger = logger;
|
||||
@@ -161,22 +179,29 @@ export class MonitorService implements IMonitorService {
|
||||
this.monitorsRepository = monitorsRepository;
|
||||
this.checksRepository = checksRepository;
|
||||
this.geoChecksRepository = geoChecksRepository;
|
||||
this.dockerLogsRepository = dockerLogsRepository;
|
||||
this.monitorStatsRepository = monitorStatsRepository;
|
||||
this.statusPagesRepository = statusPagesRepository;
|
||||
this.incidentsRepository = incidentsRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
|
||||
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<Monitor> => {
|
||||
// proxyId is only needed in custom mode
|
||||
if (body.proxyMode !== "custom") {
|
||||
delete body.proxyId;
|
||||
}
|
||||
|
||||
// Encrypt docker TLS keys
|
||||
this.encryptDockerTls(body, "create");
|
||||
|
||||
const monitor = await this.monitorsRepository.create(body, teamId, userId);
|
||||
if (!monitor) {
|
||||
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
|
||||
}
|
||||
|
||||
this.scheduler.addJob(monitor.id, monitor);
|
||||
return monitor;
|
||||
};
|
||||
|
||||
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
|
||||
@@ -385,6 +410,41 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
};
|
||||
|
||||
getDockerContainerLogs = async ({
|
||||
teamId,
|
||||
monitorId,
|
||||
containerName,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
}: {
|
||||
teamId: string;
|
||||
monitorId: string;
|
||||
containerName: string;
|
||||
before?: Date;
|
||||
after?: Date;
|
||||
limit: number;
|
||||
}): Promise<DockerContainerLogsResult> => {
|
||||
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
|
||||
if (!monitor) {
|
||||
throw new AppError({
|
||||
message: `Monitor with ID ${monitorId} not found`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (monitor.type !== "docker") {
|
||||
throw new AppError({
|
||||
message: `${monitor.type} monitors are not supported for docker container logs`,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, after, limit });
|
||||
const nextCursor = !after && logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null;
|
||||
return { logs, nextCursor };
|
||||
};
|
||||
|
||||
getGeoChecksByMonitorId = async ({
|
||||
teamId,
|
||||
monitorId,
|
||||
@@ -487,6 +547,20 @@ export class MonitorService implements IMonitorService {
|
||||
if (unsetProxyId) {
|
||||
delete body.proxyId;
|
||||
}
|
||||
|
||||
// Handle Docker TLS
|
||||
this.encryptDockerTls(body, "edit");
|
||||
if (body.type === "docker" && isDockerTlsUrl(body.url) && body.dockerTlsKey === undefined) {
|
||||
const stored = await this.monitorsRepository.findById(monitorId, teamId);
|
||||
if (!stored.dockerTlsKeySet) {
|
||||
throw new AppError({
|
||||
message: "Key is required for a TLS Docker host",
|
||||
status: 422,
|
||||
service: SERVICE_NAME,
|
||||
method: "editMonitor",
|
||||
});
|
||||
}
|
||||
}
|
||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
|
||||
await this.scheduler.updateJob(editedMonitor);
|
||||
return editedMonitor;
|
||||
@@ -593,6 +667,14 @@ export class MonitorService implements IMonitorService {
|
||||
});
|
||||
});
|
||||
|
||||
await this.dockerLogsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
||||
this.logger.warn({
|
||||
message: `Error deleting docker logs for monitor ${monitor.id} with name ${monitor.name}`,
|
||||
service: SERVICE_NAME,
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
||||
this.logger.warn({
|
||||
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
|
||||
@@ -666,4 +748,34 @@ export class MonitorService implements IMonitorService {
|
||||
|
||||
return { imported: createdMonitors!.length, errors };
|
||||
};
|
||||
|
||||
private encryptDockerTls = (body: Partial<Monitor>, mode: "create" | "edit") => {
|
||||
if (body.type !== "docker" && mode === "create") return;
|
||||
|
||||
if (body.url !== undefined && !isDockerTlsUrl(body.url)) {
|
||||
body.dockerTlsCa = undefined;
|
||||
body.dockerTlsCert = undefined;
|
||||
body.dockerTlsKey = undefined;
|
||||
body.dockerTlsKeySet = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!body.dockerTlsKey) {
|
||||
delete body.dockerTlsKey; // empty field means keeps stored key
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.encryptionService.isConfigured()) {
|
||||
throw new AppError({
|
||||
message:
|
||||
'Docker TLS credentials require ENCRYPTION_KEY to be set on the server. Generate one with "openssl rand -base64 32", set the same value for both the API and worker, and restart.',
|
||||
status: 422,
|
||||
service: SERVICE_NAME,
|
||||
method: "encryptDockerTls",
|
||||
});
|
||||
}
|
||||
|
||||
body.dockerTlsKey = this.encryptionService.encrypt(body.dockerTlsKey);
|
||||
body.dockerTlsKeySet = true;
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
|
||||
import http from "node:http";
|
||||
import { HardwareStats } from "@/domain/checks/check.type.js";
|
||||
import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js";
|
||||
import { DockerLogPage } from "@/domain/docker/docker-log.type.js";
|
||||
|
||||
export const HttpStatusCodes = [
|
||||
...Object.keys(http.STATUS_CODES).map(Number),
|
||||
@@ -91,7 +92,6 @@ export interface Monitor {
|
||||
tags: string[];
|
||||
customUpCodes: HttpStatusCode[];
|
||||
secret?: string;
|
||||
sshPrivateKey?: string;
|
||||
cpuAlertThreshold: number;
|
||||
cpuAlertCounter: number;
|
||||
memoryAlertThreshold: number;
|
||||
@@ -108,6 +108,12 @@ export interface Monitor {
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dockerLogsEnabled?: boolean;
|
||||
dockerTlsCa?: string;
|
||||
dockerTlsCert?: string;
|
||||
// EncryptionService ciphertext. Normally excluded
|
||||
dockerTlsKey?: string;
|
||||
dockerTlsKeySet?: boolean;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
@@ -189,3 +195,5 @@ export interface Game {
|
||||
export type GamesMap = Record<string, Game>;
|
||||
|
||||
export type MonitorScheduleFields = Pick<Monitor, "id" | "type" | "isActive" | "interval" | "geoCheckEnabled" | "geoCheckInterval">;
|
||||
|
||||
export type DockerContainerLogsResult = DockerLogPage;
|
||||
@@ -37,6 +37,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"signalgrid",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as NotificationChannel[],
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NotificationChannels = [
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"signalgrid",
|
||||
"twilio",
|
||||
"ntfy",
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
const SERVICE_NAME = "SignalgridProvider";
|
||||
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
|
||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
||||
import got from "got";
|
||||
|
||||
export class SignalgridProvider extends NotificationProvider {
|
||||
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
|
||||
if (!notification.address || !notification.accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post("https://api.signalgrid.co/v1/push", {
|
||||
form: {
|
||||
client_key: notification.accessToken,
|
||||
channel: notification.address,
|
||||
title: "Checkmate Test Notification",
|
||||
body: getTestMessage(),
|
||||
type: "INFO",
|
||||
critical: false,
|
||||
},
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : "unknown error";
|
||||
const errStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.warn({
|
||||
message: "Signalgrid test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: errStack,
|
||||
details: { error: errMsg },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||
if (!notification.address || !notification.accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post("https://api.signalgrid.co/v1/push", {
|
||||
form: {
|
||||
client_key: notification.accessToken,
|
||||
channel: notification.address,
|
||||
title: message.content.title,
|
||||
body: this.buildSignalgridText(message),
|
||||
type: this.mapSeverity(message.severity),
|
||||
critical: false,
|
||||
},
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
|
||||
this.logger.info({
|
||||
message: "Signalgrid notification sent",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : "unknown error";
|
||||
const errStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.warn({
|
||||
message: "Signalgrid alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: errStack,
|
||||
details: { error: errMsg },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "CRIT";
|
||||
case "warning":
|
||||
return "WARN";
|
||||
case "success":
|
||||
return "SUCCESS";
|
||||
default:
|
||||
return "INFO";
|
||||
}
|
||||
}
|
||||
|
||||
private buildSignalgridText(message: NotificationMessage): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(message.content.summary);
|
||||
lines.push("");
|
||||
lines.push("Monitor Details:");
|
||||
lines.push(`• Name: ${message.monitor.name}`);
|
||||
lines.push(`• URL: ${message.monitor.url}`);
|
||||
lines.push(`• Type: ${message.monitor.type}`);
|
||||
lines.push(`• Status: ${message.monitor.status}`);
|
||||
|
||||
if (message.content.details && message.content.details.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Additional Information:");
|
||||
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
|
||||
}
|
||||
|
||||
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Threshold Breaches:");
|
||||
message.content.thresholds.forEach((breach) => {
|
||||
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (message.content.incident) {
|
||||
lines.push("");
|
||||
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -45,7 +45,7 @@ const startApp = async () => {
|
||||
// ***********************
|
||||
// Build shared services
|
||||
// ***********************
|
||||
const shared = await buildShared({ logger, envSettings, settingsService });
|
||||
const shared = await buildShared({ logger, envSettings, settingsService, encryptionKeys: env.ENCRYPTION_KEY });
|
||||
|
||||
// ***********************
|
||||
// Worker node path, don't need API
|
||||
|
||||
@@ -5,14 +5,18 @@ import type { ILogger } from "@/utils/logger.js";
|
||||
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
||||
import { ICheckService } from "../domain/checks/check.service.js";
|
||||
import { DockerLog } from "@/domain/docker/docker-log.type.js";
|
||||
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
|
||||
const SERVICE_NAME = "BufferService";
|
||||
|
||||
export interface IBufferService {
|
||||
addToBuffer(check: Check): void;
|
||||
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
|
||||
addDockerLogToBuffer(dockerLog: DockerLog): void;
|
||||
scheduleNextFlush(): void;
|
||||
flushBuffer(): Promise<void>;
|
||||
flushGeoBuffer(): Promise<void>;
|
||||
flushDockerLogsBuffer(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -23,15 +27,18 @@ export class BufferService implements IBufferService {
|
||||
private SERVICE_NAME: string;
|
||||
private buffer: Check[];
|
||||
private geoBuffer: GeoCheck[];
|
||||
private dockerLogBuffer: DockerLog[];
|
||||
private bufferTimer: NodeJS.Timeout | null = null;
|
||||
private checksService: ICheckService;
|
||||
private geoChecksService: IGeoChecksService;
|
||||
private dockerLogsService: IDockerLogsService;
|
||||
private jobsRepository: IJobsRepository;
|
||||
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
checkService: ICheckService,
|
||||
geoChecksService: IGeoChecksService,
|
||||
dockerLogsService: IDockerLogsService,
|
||||
settingsService: ISettingsService,
|
||||
jobsRepository: IJobsRepository
|
||||
) {
|
||||
@@ -39,10 +46,12 @@ export class BufferService implements IBufferService {
|
||||
this.logger = logger;
|
||||
this.checksService = checkService;
|
||||
this.geoChecksService = geoChecksService;
|
||||
this.dockerLogsService = dockerLogsService;
|
||||
this.jobsRepository = jobsRepository;
|
||||
this.SERVICE_NAME = SERVICE_NAME;
|
||||
this.buffer = [];
|
||||
this.geoBuffer = [];
|
||||
this.dockerLogBuffer = [];
|
||||
this.scheduleNextFlush();
|
||||
this.logger.info({
|
||||
message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`,
|
||||
@@ -77,6 +86,19 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
}
|
||||
|
||||
addDockerLogToBuffer(dockerLog: DockerLog): void {
|
||||
try {
|
||||
this.dockerLogBuffer.push(dockerLog);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "addDockerLogToBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextFlush() {
|
||||
if (this.bufferTimer) {
|
||||
clearTimeout(this.bufferTimer);
|
||||
@@ -85,6 +107,7 @@ export class BufferService implements IBufferService {
|
||||
try {
|
||||
await this.flushBuffer();
|
||||
await this.flushGeoBuffer();
|
||||
await this.flushDockerLogsBuffer();
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
@@ -150,6 +173,30 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
}
|
||||
|
||||
async flushDockerLogsBuffer() {
|
||||
if (this.dockerLogBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Take the batch first so a write that drains it can't be appended to mid-flush
|
||||
const batch = this.dockerLogBuffer;
|
||||
this.dockerLogBuffer = [];
|
||||
try {
|
||||
this.logger.debug({
|
||||
message: `Flushing ${batch.length} docker logs to database`,
|
||||
service: this.SERVICE_NAME,
|
||||
method: "flushDockerLogsBuffer",
|
||||
});
|
||||
await this.dockerLogsService.createDockerLogs(batch);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
service: this.SERVICE_NAME,
|
||||
method: "flushDockerLogsBuffer",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this.bufferTimer) {
|
||||
clearTimeout(this.bufferTimer);
|
||||
@@ -157,5 +204,6 @@ export class BufferService implements IBufferService {
|
||||
}
|
||||
await this.flushBuffer();
|
||||
await this.flushGeoBuffer();
|
||||
await this.flushDockerLogsBuffer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import type * as nodeCrypto from "node:crypto";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
|
||||
const SERVICE_NAME = "EncryptionService";
|
||||
|
||||
const ENCRYPTION_KEY_BYTES = 32;
|
||||
|
||||
// Encryption consts
|
||||
const FORMAT_VERSION = "v1";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
const KEY_ID_BYTES = 4;
|
||||
const SEGMENT_COUNT = 5;
|
||||
type V1Segments = [string, string, string, string, string];
|
||||
const isV1Segments = (segments: string[]): segments is V1Segments => segments.length === SEGMENT_COUNT;
|
||||
const BASE64URL = /^[A-Za-z0-9_-]*$/;
|
||||
|
||||
type KeyEntry = { id: string; key: Buffer };
|
||||
|
||||
export type EncryptionCryptoLib = Pick<typeof nodeCrypto, "createCipheriv" | "createDecipheriv" | "createHash" | "randomBytes">;
|
||||
|
||||
export interface IEncryptionService {
|
||||
isConfigured(): boolean;
|
||||
currentKeyId(): string;
|
||||
keyIdOf(ciphertext: string): string;
|
||||
needsReencryption(ciphertext: string): boolean;
|
||||
encrypt(plaintext: string): string;
|
||||
decrypt(ciphertext: string): string;
|
||||
}
|
||||
|
||||
export class EncryptionService implements IEncryptionService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
private readonly entries: KeyEntry[];
|
||||
|
||||
constructor(
|
||||
keys: Buffer[],
|
||||
private logger: ILogger,
|
||||
private cryptoLib: EncryptionCryptoLib
|
||||
) {
|
||||
this.entries = keys.map((key, index) => this.toEntry(key, index));
|
||||
this.entries.forEach((entry, index) => {
|
||||
if (this.entries.findIndex((other) => other.id === entry.id) !== index) {
|
||||
throw this.fail("constructor", "ENCRYPTION_KEY key ids collide; regenerate one key", { index });
|
||||
}
|
||||
});
|
||||
|
||||
const current = this.entries[0];
|
||||
|
||||
this.logger.info({
|
||||
message: current
|
||||
? `Encryption configured with ${this.entries.length} key(s); current key id ${current.id}`
|
||||
: "ENCRYPTION_KEY not set; encrypted fields are unavailable",
|
||||
service: SERVICE_NAME,
|
||||
method: "constructor",
|
||||
});
|
||||
}
|
||||
|
||||
isConfigured = () => {
|
||||
return this.entries.length > 0;
|
||||
};
|
||||
|
||||
currentKeyId = () => {
|
||||
return this.requireCurrentKey("currentKeyId").id;
|
||||
};
|
||||
|
||||
keyIdOf = (ciphertext: string) => {
|
||||
return this.parse(ciphertext, "keyIdOf").keyId;
|
||||
};
|
||||
|
||||
// Check if the ciphertext was encrypted with the current key
|
||||
needsReencryption = (ciphertext: string) => {
|
||||
const currentKey = this.requireCurrentKey("needsReencryption");
|
||||
return this.parse(ciphertext, "needsReencryption").keyId !== currentKey.id;
|
||||
};
|
||||
|
||||
encrypt = (plaintext: string) => {
|
||||
const currentKey = this.requireCurrentKey("encrypt"); // Get the current key for encryption
|
||||
const iv = this.cryptoLib.randomBytes(IV_BYTES); // Get IV_BYTES worth of random bytes for nonce
|
||||
const cipher = this.cryptoLib.createCipheriv(ALGORITHM, currentKey.key, iv, { authTagLength: TAG_BYTES }); // Create the cipher
|
||||
cipher.setAAD(this.aad(currentKey.id)); // set signing key as additional data so it is protected by the verification tag
|
||||
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); // Encode plaintext as utf8 and run through cipher
|
||||
const tag = cipher.getAuthTag(); // Get verification tag
|
||||
return [FORMAT_VERSION, currentKey.id, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join(".");
|
||||
};
|
||||
|
||||
decrypt = (ciphertext: string) => {
|
||||
this.requireCurrentKey("decrypt"); // Config check
|
||||
const { keyId, iv, tag, data } = this.parse(ciphertext, "decrypt");
|
||||
const key = this.entries.find((entry) => entry.id === keyId)?.key;
|
||||
if (!key) {
|
||||
throw this.fail("decrypt", "No encryption key matches ciphertext", { keyId });
|
||||
}
|
||||
const decipher = this.cryptoLib.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
|
||||
decipher.setAAD(this.aad(keyId)); // reconstruct the additional data, must be done BEFORE auth tag
|
||||
decipher.setAuthTag(tag); // Give the decipher the tag to verify
|
||||
try {
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw this.fail("decrypt", "Ciphertext failed authentication");
|
||||
}
|
||||
};
|
||||
|
||||
private fail = (method: string, message: string, details?: Record<string, unknown>): AppError => {
|
||||
return new AppError({
|
||||
message,
|
||||
status: 500,
|
||||
service: SERVICE_NAME,
|
||||
method,
|
||||
details,
|
||||
});
|
||||
};
|
||||
|
||||
// Create an ID:Key pair for each key.
|
||||
private toEntry = (key: Buffer, keyIdx: number): KeyEntry => {
|
||||
if (key.length !== ENCRYPTION_KEY_BYTES) throw this.fail("constructor", "ENCRYPTION_KEY is not 32 bytes", { keyIdx });
|
||||
|
||||
const id = this.cryptoLib
|
||||
.createHash("sha256") // Create a sha256 hasher
|
||||
.update(key) // Feed it the key
|
||||
.digest() // Digest a hash
|
||||
.subarray(0, KEY_ID_BYTES) // Keep first KEY_ID_BYTES, enough to avoid collision
|
||||
.toString("base64url");
|
||||
return { id, key };
|
||||
};
|
||||
|
||||
private requireCurrentKey = (method: string): KeyEntry => {
|
||||
const current = this.entries[0];
|
||||
if (!current) {
|
||||
throw this.fail(method, "ENCRYPTION_KEY is not configured");
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// v1 Ciphertext stored as a string with five segments:
|
||||
// <version>.<keyId>.<iv>.<tag>.<encryptedData>
|
||||
// iv and tag belong to aes-256-gcm standard
|
||||
|
||||
private parse = (ciphertext: string, method: string) => {
|
||||
const segments = ciphertext.split(".");
|
||||
const version = segments[0];
|
||||
if (version !== FORMAT_VERSION) {
|
||||
throw this.fail(method, "Unsupported ciphertext version", { version });
|
||||
}
|
||||
|
||||
// **************************
|
||||
// v1 parser
|
||||
// **************************
|
||||
if (!isV1Segments(segments)) {
|
||||
throw this.fail(method, "Ciphertext is malformed");
|
||||
}
|
||||
|
||||
const [, keyId, ivText, tagText, dataText] = segments; // Skip first segment, we already have version
|
||||
|
||||
if (![keyId, ivText, tagText, dataText].every((segment) => BASE64URL.test(segment))) {
|
||||
throw this.fail(method, "Ciphertext is malformed");
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivText, "base64url");
|
||||
const tag = Buffer.from(tagText, "base64url");
|
||||
|
||||
if (keyId.length === 0 || iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
||||
throw this.fail(method, "Ciphertext is malformed");
|
||||
}
|
||||
return { keyId, iv, tag, data: Buffer.from(dataText, "base64url") };
|
||||
// **************************
|
||||
// End v1 parser
|
||||
// **************************
|
||||
};
|
||||
|
||||
private aad = (keyId: string) => {
|
||||
return Buffer.from(`${FORMAT_VERSION}.${keyId}`, "utf8");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
|
||||
const SERVICE_NAME = "SecretsRotationService";
|
||||
|
||||
export interface ISecretsRotationService {
|
||||
run(): Promise<{ scanned: number; reencrypted: number; skipped: number }>;
|
||||
}
|
||||
|
||||
export class SecretsRotationService implements ISecretsRotationService {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
|
||||
constructor(
|
||||
private monitorsRepository: IMonitorsRepository,
|
||||
private encryptionService: IEncryptionService,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
|
||||
run = async () => {
|
||||
const rows = await this.monitorsRepository.findAllDockerTlsKeys();
|
||||
const result = { scanned: rows.length, reencrypted: 0, skipped: 0 };
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
if (!this.encryptionService.isConfigured()) {
|
||||
this.logger.warn({
|
||||
message: `${rows.length} monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is`,
|
||||
service: SERVICE_NAME,
|
||||
method: "run",
|
||||
});
|
||||
result.skipped = rows.length;
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const monitor of rows) {
|
||||
const { id, dockerTlsKey } = monitor;
|
||||
try {
|
||||
if (!this.encryptionService.needsReencryption(dockerTlsKey)) continue;
|
||||
const plainText = this.encryptionService.decrypt(dockerTlsKey);
|
||||
await this.monitorsRepository.updateDockerTlsKey(id, this.encryptionService.encrypt(plainText));
|
||||
result.reencrypted += 1;
|
||||
} catch (error: unknown) {
|
||||
result.skipped += 1;
|
||||
this.logger.warn({
|
||||
message: `Could not reencrypt Docker TLS key for monitor ${id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "run",
|
||||
details: { monitorId: id, ...(error instanceof AppError ? error.details : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
this.logger.info({
|
||||
message: `Docker TLS key rotation: ${result.reencrypted} re-encrypted, ${result.skipped} skipped, ${result.scanned} scanned`,
|
||||
service: SERVICE_NAME,
|
||||
method: "run",
|
||||
});
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import https from "node:https";
|
||||
|
||||
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
|
||||
import { CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
|
||||
import {
|
||||
DOCKER_LOG_TAIL_LINES,
|
||||
DockerContainerInfo,
|
||||
DockerContainerLogs,
|
||||
DockerContainerMount,
|
||||
DockerContainerPort,
|
||||
DockerContainerState,
|
||||
@@ -8,21 +14,32 @@ import {
|
||||
DockerContainerSummary,
|
||||
DockerHealthStatus,
|
||||
DockerHealthStatuses,
|
||||
DockerLogLine,
|
||||
DockerLogStream,
|
||||
DockerPortProtocol,
|
||||
DockerPortProtocols,
|
||||
DockerStatusPayload,
|
||||
MonitorStatusResponse,
|
||||
} from "@/types/network.js";
|
||||
} from "@/domain/docker/docker.type.js";
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import Dockerode from "dockerode";
|
||||
import { timeRequest } from "@/service/network/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
||||
import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||
import { splitCertificateBundle } from "@/utils/pem.js";
|
||||
|
||||
type DockerodeType = typeof Dockerode;
|
||||
type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent; connectionTimeout?: number };
|
||||
type TlsCredentials = { ok: true; key: string } | { ok: false; message: string };
|
||||
|
||||
const SERVICE_NAME = "DockerProvider";
|
||||
const TIMEOUT_MS = 10000;
|
||||
const STATS_CONCURRENCY = 5;
|
||||
const DOCKER_LOG_MAX_LINE_BYTES = 4096;
|
||||
const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]";
|
||||
const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
|
||||
const DOCKER_TLS_DEFAULT_PORT = 2376;
|
||||
|
||||
export interface DockerError extends Error {
|
||||
statusCode?: number;
|
||||
@@ -35,51 +52,72 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
|
||||
constructor(
|
||||
private logger: ILogger,
|
||||
private DockerLib: DockerodeType
|
||||
private DockerLib: DockerodeType,
|
||||
private encryptionService: IEncryptionService
|
||||
) {}
|
||||
|
||||
supports(type: MonitorType): boolean {
|
||||
return type === "docker";
|
||||
}
|
||||
|
||||
private resolveTlsCredentials = (monitor: Monitor, ctx?: CheckContext): TlsCredentials | undefined => {
|
||||
if (!isDockerTlsUrl(monitor.url)) return undefined;
|
||||
if (!ctx?.dockerTlsKey) return { ok: false, message: "Docker TLS key is missing" };
|
||||
try {
|
||||
return { ok: true, key: this.encryptionService.decrypt(ctx.dockerTlsKey) };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
const keyId = typeof error.details?.keyId === "string" ? ` (key id ${error.details.keyId})` : "";
|
||||
return { ok: false, message: `${error.message}${keyId}` };
|
||||
}
|
||||
return { ok: false, message: "Docker TLS key could not be decrypted" };
|
||||
}
|
||||
};
|
||||
|
||||
private isDockerError(error: unknown): error is DockerError {
|
||||
return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error);
|
||||
}
|
||||
|
||||
private toDockerOptions = (monitor: Monitor): Dockerode.DockerOptions => {
|
||||
private splitCaBundle = (pem: string | undefined): string[] | undefined => {
|
||||
const blocks = pem ? splitCertificateBundle(pem) : [];
|
||||
return blocks.length > 0 ? blocks : undefined;
|
||||
};
|
||||
|
||||
private invalidUrl = (message: string) => new AppError({ message, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
|
||||
|
||||
private toDockerOptions = (monitor: Monitor, credentials?: TlsCredentials): DockerOptions => {
|
||||
const url = monitor.url?.trim();
|
||||
if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
|
||||
if (!url) throw this.invalidUrl("Docker host URL is required");
|
||||
|
||||
// Unix socket
|
||||
if (url.startsWith("unix://")) {
|
||||
const socketPath = url.slice("unix://".length);
|
||||
if (!socketPath.startsWith("/"))
|
||||
throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
|
||||
return { socketPath };
|
||||
if (!socketPath.startsWith("/")) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
|
||||
return { socketPath, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
|
||||
}
|
||||
if (url.startsWith("/")) return { socketPath: url };
|
||||
const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/);
|
||||
if (!match) throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
|
||||
const [, username, host, port] = match;
|
||||
if (!username)
|
||||
throw new AppError({
|
||||
message: "SSH Docker host URL requires a user: ssh://user@host",
|
||||
status: 422,
|
||||
service: SERVICE_NAME,
|
||||
method: "toDockerOptions",
|
||||
});
|
||||
if (url.startsWith("/")) return { socketPath: url, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
|
||||
|
||||
if (!monitor.sshPrivateKey)
|
||||
throw new AppError({
|
||||
message: "SSH Docker host requires a private key",
|
||||
status: 422,
|
||||
service: SERVICE_NAME,
|
||||
method: "toDockerOptions",
|
||||
});
|
||||
// Docker TLS
|
||||
const match = DOCKER_TLS_URL.exec(url);
|
||||
if (!match) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
|
||||
if (!credentials?.ok) throw this.invalidUrl(credentials?.message ?? "Docker TLS key is missing");
|
||||
const [, , host, port] = match;
|
||||
const ca = monitor.ignoreTlsErrors ? undefined : this.splitCaBundle(monitor.dockerTlsCa);
|
||||
const cert = monitor.dockerTlsCert;
|
||||
const key = credentials.key;
|
||||
return {
|
||||
protocol: "ssh",
|
||||
host,
|
||||
port: port ? Number(port) : 22,
|
||||
username,
|
||||
sshOptions: { privateKey: monitor.sshPrivateKey },
|
||||
port: port ? Number(port) : DOCKER_TLS_DEFAULT_PORT,
|
||||
protocol: "https",
|
||||
ca,
|
||||
cert,
|
||||
key,
|
||||
agent: new https.Agent({ ca, cert, key, rejectUnauthorized: !monitor.ignoreTlsErrors }),
|
||||
socketPath: undefined,
|
||||
username: undefined,
|
||||
sshOptions: undefined,
|
||||
timeout: TIMEOUT_MS,
|
||||
connectionTimeout: TIMEOUT_MS,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,10 +157,14 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
private toContainerName = (summary: Dockerode.ContainerInfo): string => {
|
||||
return summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12);
|
||||
};
|
||||
|
||||
private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise<DockerContainerInfo> {
|
||||
const info: DockerContainerInfo = {
|
||||
id: summary.Id,
|
||||
name: summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12),
|
||||
name: this.toContainerName(summary),
|
||||
image: summary.Image,
|
||||
state: this.toContainerState(summary.State),
|
||||
status: summary.Status,
|
||||
@@ -198,9 +240,98 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
}));
|
||||
}
|
||||
|
||||
handle = async (monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
|
||||
// Log parsing
|
||||
private isMultiplexed(buffer: Buffer): boolean {
|
||||
return buffer.length >= 8 && buffer.readUInt8(0) <= 2 && buffer.readUInt8(1) === 0 && buffer.readUInt8(2) === 0 && buffer.readUInt8(3) === 0;
|
||||
}
|
||||
|
||||
// Docker will will merge stdout and stderr if stared with `-t`.
|
||||
// Most containers will _not_ be run with `-t` though, in which case logs come in frames of [header][payload]
|
||||
private demuxLogBuffer = (buffer: Buffer): { stream: DockerLogStream; chunk: string }[] => {
|
||||
if (!this.isMultiplexed(buffer)) return [{ stream: "stdout", chunk: buffer.toString("utf8") }]; // `-t` case, log is just a string, return it
|
||||
const frames: { stream: DockerLogStream; chunk: string }[] = [];
|
||||
let offset = 0; // Start reading at the first byte of the first header
|
||||
while (offset + 8 <= buffer.length) {
|
||||
// Every 8 bytes is a header, if < 8 bytes at the end, invalid frame, stop
|
||||
const streamType = buffer.readUint8(offset); // First byte of the header is the stream type: 0 = stdin, 1 = stdout, 2 = stderr
|
||||
const payloadSize = buffer.readUint32BE(offset + 4); // Byte 4 to 7 describe how many payload bytes follow the header
|
||||
const payloadStart = offset + 8; // Advance to the payload (header 8 bytes long)
|
||||
const payloadEnd = Math.min(payloadStart + payloadSize, buffer.length); // End is the start of the payload + payload size.
|
||||
frames.push({ stream: streamType === 2 ? "stderr" : "stdout", chunk: buffer.toString("utf-8", payloadStart, payloadEnd) });
|
||||
offset = payloadEnd;
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
private truncateLog = (text: string): string => {
|
||||
if (Buffer.byteLength(text, "utf8") <= DOCKER_LOG_MAX_LINE_BYTES) return text;
|
||||
const cut = Buffer.from(text, "utf8").subarray(0, DOCKER_LOG_MAX_LINE_BYTES).toString("utf8").replace(/�$/, "");
|
||||
return cut + DOCKER_LOG_TRUNCATION_MARKER;
|
||||
};
|
||||
|
||||
// Docker drops trailing zeroes form timestamps. Pad out timestamps for correct string comparison
|
||||
private toLogLine(stream: DockerLogStream, raw: string): DockerLogLine | null {
|
||||
const match = raw.match(DOCKER_LOG_TS_REGEX);
|
||||
if (!match) return null; // If there's no time stamp, something is wrong with this line, drop it
|
||||
const ts = `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`;
|
||||
const text = raw.slice(match[0].length).replace(/\r$/, "");
|
||||
return { ts, stream, text: this.truncateLog(text) };
|
||||
}
|
||||
|
||||
private parseLogBuffer = (buffer: Buffer): DockerLogLine[] => {
|
||||
const lines: DockerLogLine[] = [];
|
||||
const demuxedBuffer = this.demuxLogBuffer(buffer);
|
||||
for (const byteStream of demuxedBuffer) {
|
||||
const { stream, chunk } = byteStream;
|
||||
for (const raw of chunk.split("\n")) {
|
||||
if (raw.length === 0) continue;
|
||||
const line = this.toLogLine(stream, raw);
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
private toContainerLogs = async (docker: Dockerode, summary: Dockerode.ContainerInfo): Promise<DockerContainerLogs | null> => {
|
||||
const containerName = this.toContainerName(summary);
|
||||
try {
|
||||
const docker = new this.DockerLib(this.toDockerOptions(monitor));
|
||||
const buffer = await docker
|
||||
.getContainer(summary.Id)
|
||||
.logs({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES });
|
||||
return { containerId: summary.Id, containerName, lines: this.parseLogBuffer(buffer) };
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn({
|
||||
message: `Failed to read logs for container ${containerName}`,
|
||||
service: SERVICE_NAME,
|
||||
method: "toContainerLogs",
|
||||
details: { containerId: summary.Id, error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
private collectLogs = async (docker: Dockerode, summaries: Dockerode.ContainerInfo[]): Promise<DockerContainerLogs[]> => {
|
||||
const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s));
|
||||
return rawLogs.filter((log) => log !== null);
|
||||
};
|
||||
|
||||
private failed = (monitor: Monitor, code: number, message: string, responseTime: number): MonitorStatusResponse<DockerStatusPayload> => ({
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code,
|
||||
message,
|
||||
responseTime,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
handle = async (monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<DockerStatusPayload>> => {
|
||||
try {
|
||||
const credentials = this.resolveTlsCredentials(monitor, ctx);
|
||||
if (credentials && !credentials.ok) return this.failed(monitor, NETWORK_ERROR, credentials.message, 0);
|
||||
|
||||
const docker = new this.DockerLib(this.toDockerOptions(monitor, credentials));
|
||||
// Host reachability is the monitor's status; ping latency is the responseTime
|
||||
const { responseTime, error } = await timeRequest(() => docker.ping());
|
||||
if (error) {
|
||||
@@ -212,20 +343,13 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code,
|
||||
message,
|
||||
responseTime,
|
||||
payload: null,
|
||||
};
|
||||
return this.failed(monitor, code, message, responseTime);
|
||||
}
|
||||
|
||||
const summaries = await docker.listContainers({ all: true });
|
||||
const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s));
|
||||
const logs = monitor.dockerLogsEnabled ? await this.collectLogs(docker, summaries) : undefined;
|
||||
|
||||
const summary: DockerContainerSummary = {
|
||||
total: containers.length,
|
||||
running: containers.filter((c) => c.state === "running").length,
|
||||
@@ -240,7 +364,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
code: 200,
|
||||
message: "Docker host is reachable",
|
||||
responseTime,
|
||||
payload: { containers, summary },
|
||||
payload: { containers, summary, logs },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppError) throw error;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GotTimings,
|
||||
ILighthouseAudit,
|
||||
} from "@/domain/checks/check.type.js";
|
||||
import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
||||
import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
|
||||
import type { QueryResult } from "gamedig";
|
||||
@@ -97,58 +98,11 @@ export interface HardwareStatusPayload {
|
||||
}
|
||||
|
||||
// Docker host monitoring
|
||||
export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const;
|
||||
export type DockerContainerState = (typeof DockerContainerStates)[number];
|
||||
|
||||
export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
|
||||
export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
|
||||
|
||||
export interface DockerContainerSummary {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
unhealthy: number;
|
||||
}
|
||||
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: DockerContainerState;
|
||||
status: string;
|
||||
health: DockerHealthStatus;
|
||||
cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores
|
||||
memoryUsedBytes?: number;
|
||||
memoryLimitBytes?: number;
|
||||
memoryPct?: number; // 0-1 fraction of the memory limit
|
||||
restartCount?: number;
|
||||
startedAt?: string; // ISO date
|
||||
ports?: DockerContainerPort[];
|
||||
mounts?: DockerContainerMount[];
|
||||
}
|
||||
|
||||
export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
|
||||
export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
|
||||
|
||||
export interface DockerContainerPort {
|
||||
privatePort: number;
|
||||
protocol: DockerPortProtocol;
|
||||
publicPort?: number;
|
||||
hostIp?: string;
|
||||
}
|
||||
|
||||
export interface DockerContainerMount {
|
||||
type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, …
|
||||
name?: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
rw: boolean;
|
||||
}
|
||||
|
||||
export interface DockerStatusPayload {
|
||||
containers: DockerContainerInfo[];
|
||||
summary: DockerContainerSummary;
|
||||
logs?: DockerContainerLogs[];
|
||||
}
|
||||
|
||||
export interface PortStatusPayload {
|
||||
@@ -208,4 +162,5 @@ export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusRespon
|
||||
|
||||
export type CheckContext = {
|
||||
proxyUrl?: string;
|
||||
dockerTlsKey?: string;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DOCKER_TLS_URL = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
|
||||
export const DOCKER_SOCKET_URL = /^(?:unix:\/\/\/\S+|\/\S+)$/;
|
||||
|
||||
export const isDockerTlsUrl = (url: string | undefined): boolean => DOCKER_TLS_URL.test(url?.trim() ?? "");
|
||||
export const isDockerSocketUrl = (url: string | undefined): boolean => DOCKER_SOCKET_URL.test(url?.trim() ?? "");
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createPrivateKey, KeyObject, X509Certificate } from "node:crypto";
|
||||
|
||||
const PEM_CERT_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
export const splitCertificateBundle = (pem: string): string[] => pem.match(PEM_CERT_BLOCK) ?? [];
|
||||
|
||||
export const parseCertificates = (pem: string): X509Certificate[] => {
|
||||
const blocks = splitCertificateBundle(pem);
|
||||
if (blocks.length === 0) throw new Error("No certificate found; expected one or more PEM CERTIFICATE blocks");
|
||||
try {
|
||||
return blocks.map((block) => new X509Certificate(block));
|
||||
} catch {
|
||||
throw new Error("Certificate is not valid PEM");
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePrivateKey = (pem: string): KeyObject => {
|
||||
if (/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m.test(pem) || /^Proc-Type: 4,ENCRYPTED/m.test(pem)) {
|
||||
throw new Error("Encrypted private keys are not supported; remove the passphrase first");
|
||||
}
|
||||
try {
|
||||
return createPrivateKey(pem);
|
||||
} catch {
|
||||
throw new Error("Private key is not valid PEM");
|
||||
}
|
||||
};
|
||||
|
||||
export const keyMatchesCertificate = (key: KeyObject, certificate: X509Certificate): boolean => certificate.checkPrivateKey(key);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||
import { Check } from "@/domain/checks/check.type.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IBufferService } from "@/service/bufferService.js";
|
||||
@@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf
|
||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
|
||||
|
||||
export interface ICheckProducer {
|
||||
produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>;
|
||||
@@ -25,6 +26,7 @@ export class CheckProducer implements ICheckProducer {
|
||||
private networkService: INetworkService,
|
||||
private proxyResolver: IProxyResolver,
|
||||
private bufferService: IBufferService,
|
||||
private dockerLogsService: IDockerLogsService,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
|
||||
@@ -34,6 +36,12 @@ export class CheckProducer implements ICheckProducer {
|
||||
return windows.some((w) => isWindowActive(w, now));
|
||||
}
|
||||
|
||||
private async resolveDockerTlsKey(monitor: Monitor): Promise<string | undefined> {
|
||||
if (monitor.type !== "docker" || !monitor.dockerTlsKeySet || !monitor.id) return undefined;
|
||||
const dockerTlsKey = await this.monitorsRepository.findDockerTlsKeyById(monitor.id);
|
||||
return dockerTlsKey ?? undefined;
|
||||
}
|
||||
|
||||
produce = async (monitor: Monitor) => {
|
||||
if (!monitor.id) {
|
||||
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "produce" });
|
||||
@@ -59,7 +67,9 @@ export class CheckProducer implements ICheckProducer {
|
||||
|
||||
// Step 1b: Acquire status
|
||||
const proxyUrl = await this.proxyResolver.resolve(monitor);
|
||||
const status = await this.networkService.requestStatus(monitor, { proxyUrl });
|
||||
const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
|
||||
|
||||
const status = await this.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey });
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
@@ -81,6 +91,14 @@ export class CheckProducer implements ICheckProducer {
|
||||
}
|
||||
// Step 2b: Add to buffer
|
||||
this.bufferService.addToBuffer(check);
|
||||
|
||||
// Step 2c: Handle docker logs
|
||||
if (status.type === "docker") {
|
||||
const dockerLogs = await this.dockerLogsService.buildDockerLogs(status as MonitorStatusResponse<DockerStatusPayload>);
|
||||
for (const dockerLog of dockerLogs) {
|
||||
this.bufferService.addDockerLogToBuffer(dockerLog);
|
||||
}
|
||||
}
|
||||
return { status, check };
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf
|
||||
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
|
||||
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||
|
||||
export interface IWorkerHelper {
|
||||
getCleanupOrphanedJob(): () => Promise<void>;
|
||||
@@ -43,6 +44,7 @@ export class WorkerHelper implements IWorkerHelper {
|
||||
private checksRepository: IChecksRepository;
|
||||
private incidentsRepository: IIncidentsRepository;
|
||||
private geoChecksRepository: IGeoChecksRepository;
|
||||
private dockerLogsRepository: IDockerLogsRepository;
|
||||
|
||||
constructor(
|
||||
logger: ILogger,
|
||||
@@ -54,7 +56,8 @@ export class WorkerHelper implements IWorkerHelper {
|
||||
monitorStatsRepository: IMonitorStatsRepository,
|
||||
checksRepository: IChecksRepository,
|
||||
incidentsRepository: IIncidentsRepository,
|
||||
geoChecksRepository: IGeoChecksRepository
|
||||
geoChecksRepository: IGeoChecksRepository,
|
||||
dockerLogsRepository: IDockerLogsRepository
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.checkService = checkService;
|
||||
@@ -66,6 +69,7 @@ export class WorkerHelper implements IWorkerHelper {
|
||||
this.checksRepository = checksRepository;
|
||||
this.incidentsRepository = incidentsRepository;
|
||||
this.geoChecksRepository = geoChecksRepository;
|
||||
this.dockerLogsRepository = dockerLogsRepository;
|
||||
}
|
||||
|
||||
getCleanupOrphanedJob = () => {
|
||||
@@ -142,6 +146,15 @@ export class WorkerHelper implements IWorkerHelper {
|
||||
});
|
||||
}
|
||||
|
||||
const deletedDockerLogsCount = await this.dockerLogsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedDockerLogsCount > 0) {
|
||||
this.logger.info({
|
||||
message: `Deleted ${deletedDockerLogsCount} orphaned docker logs`,
|
||||
service: SERVICE_NAME,
|
||||
method: "getCleanupOrphanedJob",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove orphaned jobs
|
||||
const deletedJobsCount = await this.jobsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
|
||||
if (deletedJobsCount > 0) {
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user