diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 10bf888d9..8b1378917 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
\ No newline at end of file
+
diff --git a/README.md b/README.md
index bb5185378..1b5b74dc9 100644
--- a/README.md
+++ b/README.md
@@ -36,8 +36,7 @@
[](https://www.pikapods.com/pods?run=checkmate)
-
-
+
@@ -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 < 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/).
@@ -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!
## 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!
diff --git a/client/package-lock.json b/client/package-lock.json
index 6cee968e3..ef226f507 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,16 +1,17 @@
{
"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",
"@emotion/styled": "^11.13.0",
+ "@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
@@ -92,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",
@@ -407,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",
@@ -444,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",
@@ -950,6 +954,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@fontsource-variable/geist": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
+ "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"license": "BSD-3-Clause"
@@ -1223,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",
@@ -1327,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",
@@ -2371,6 +2386,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2386,6 +2402,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2476,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",
@@ -2756,6 +2774,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3112,6 +3131,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3555,7 +3575,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3954,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",
@@ -4766,6 +4788,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5577,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",
@@ -6358,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"
@@ -6369,6 +6394,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6572,7 +6598,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6722,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"
},
@@ -7563,6 +7591,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7700,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",
diff --git a/client/package.json b/client/package.json
index ce2e481ba..a9e53f93c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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"
@@ -21,6 +22,7 @@
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
+ "@fontsource-variable/geist": "^5.3.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
diff --git a/client/src/Components/design-elements/BaseBox.tsx b/client/src/Components/design-elements/BaseBox.tsx
index 580d15dcf..d5af820e6 100644
--- a/client/src/Components/design-elements/BaseBox.tsx
+++ b/client/src/Components/design-elements/BaseBox.tsx
@@ -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(({ sx, ...rest }, ref) => {
const theme = useTheme();
return (
{
}}
/>
);
-};
+});
+
+BaseBox.displayName = "BaseBox";
diff --git a/client/src/Components/design-elements/Icon.tsx b/client/src/Components/design-elements/Icon.tsx
index 95e68826b..f0de97dd4 100644
--- a/client/src/Components/design-elements/Icon.tsx
+++ b/client/src/Components/design-elements/Icon.tsx
@@ -8,7 +8,7 @@ interface IconProps {
color?: string;
}
-const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
+const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
return (
{
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
- backgroundColor: theme.palette.error.main,
- color: theme.palette.error.contrastText,
+ backgroundColor: theme.palette.error.dark,
+ color: "#FFFFFF",
px: theme.spacing(8),
py: theme.spacing(4),
transition: "top 1s ease-in-out",
diff --git a/client/src/Components/design-elements/PageHeader.tsx b/client/src/Components/design-elements/PageHeader.tsx
index 84b0f61c6..5bf00c151 100644
--- a/client/src/Components/design-elements/PageHeader.tsx
+++ b/client/src/Components/design-elements/PageHeader.tsx
@@ -6,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
import type { ReactNode } from "react";
@@ -109,7 +110,7 @@ export const PageHeader = ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: 1.55,
- maxWidth: 640,
+ maxWidth: "108ch",
}}
>
{description}
@@ -142,7 +143,7 @@ export const PageHeader = ({
alignItems: "center",
justifyContent: "center",
width: 36,
- height: 32,
+ height: INPUT_BASE_HEIGHT,
color: theme.palette.text.secondary,
borderRight:
idx < linkItems.length - 1
diff --git a/client/src/Components/design-elements/SkeletonCard.tsx b/client/src/Components/design-elements/SkeletonCard.tsx
index 05f0c462a..add7a69eb 100644
--- a/client/src/Components/design-elements/SkeletonCard.tsx
+++ b/client/src/Components/design-elements/SkeletonCard.tsx
@@ -1,4 +1,5 @@
import { Box, Stack, useTheme } from "@mui/material";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
const blockStyle = {
width: 55,
- height: 34,
+ height: INPUT_BASE_HEIGHT,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx
index 1543766fc..453a83687 100644
--- a/client/src/Components/design-elements/StatBox.tsx
+++ b/client/src/Components/design-elements/StatBox.tsx
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
-import { lighten } from "@mui/material/styles";
+import { darken, lighten } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
@@ -19,10 +19,10 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox)
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light";
const paper = theme.palette.background.paper;
- const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
+ const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
const paperEnd = paper;
const bg = palette
- ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
+ ? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
return (
diff --git a/client/src/Components/design-elements/StatusBox.tsx b/client/src/Components/design-elements/StatusBox.tsx
index 017873eb9..a94bd0007 100644
--- a/client/src/Components/design-elements/StatusBox.tsx
+++ b/client/src/Components/design-elements/StatusBox.tsx
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material";
-import { useTheme } from "@mui/material/styles";
+import { alpha, useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
sx={{
pointerEvents: "none",
backgroundImage: `
- linear-gradient(${theme.palette.divider} 1px, transparent 1px),
- linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
+ linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
+ linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx
index df0d52b16..09db0b4c0 100644
--- a/client/src/Components/design-elements/Table.tsx
+++ b/client/src/Components/design-elements/Table.tsx
@@ -52,6 +52,7 @@ type DataTableProps =
emptyViewText?: string;
emptyViewPositive?: boolean;
getRowSx?: (row: T) => SxProps;
+ isRowSelected?: (row: T) => boolean;
};
export function DataTable<
@@ -70,6 +71,7 @@ export function DataTable<
emptyViewText,
emptyViewPositive,
getRowSx,
+ isRowSelected,
}: DataTableProps) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
@@ -79,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) {
@@ -100,7 +117,10 @@ export function DataTable<
keys.push(key);
return (
(onRowClick ? onRowClick(row) : null)}
+ onClick={(e) => {
+ if (hasTextSelectionWithin(e.currentTarget)) return;
+ if (onRowClick) onRowClick(row);
+ }}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -197,7 +217,7 @@ export function DataTable<
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
- fontWeight: 500,
+ fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
@@ -213,10 +233,6 @@ export function DataTable<
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
- "& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root":
- {
- backgroundColor: theme.palette.action.rowHover,
- },
}}
>
@@ -237,16 +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 (
{
+ onClick={(e) => {
+ if (hasTextSelectionWithin(e.currentTarget)) return;
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
diff --git a/client/src/Components/design-elements/Tabs.tsx b/client/src/Components/design-elements/Tabs.tsx
index 149c0bd93..d87b286ef 100644
--- a/client/src/Components/design-elements/Tabs.tsx
+++ b/client/src/Components/design-elements/Tabs.tsx
@@ -1,6 +1,8 @@
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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
return (
{
iconPosition="start"
sx={{
textTransform: "none",
- fontSize: 14,
+ fontSize: typographyLevels.m,
fontWeight: 500,
- minHeight: 34,
+ minHeight: INPUT_BASE_HEIGHT,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
- fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
diff --git a/client/src/Components/inputs/Button.tsx b/client/src/Components/inputs/Button.tsx
index 4eae654b2..33bec7fe8 100644
--- a/client/src/Components/inputs/Button.tsx
+++ b/client/src/Components/inputs/Button.tsx
@@ -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 { HOVER } from "@/Utils/Theme/constants";
+import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
const PALETTE_COLORS = [
"primary",
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
{...props}
sx={{
textTransform: "none",
- height: 34,
+ height: INPUT_BASE_HEIGHT,
+ lineHeight: 1.2,
fontWeight: 400,
borderRadius: 2,
whiteSpace: "nowrap",
diff --git a/client/src/Components/inputs/ColorPicker.tsx b/client/src/Components/inputs/ColorPicker.tsx
index de736c85d..c65c3f8c7 100644
--- a/client/src/Components/inputs/ColorPicker.tsx
+++ b/client/src/Components/inputs/ColorPicker.tsx
@@ -4,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
- height: 34,
+ height: INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
diff --git a/client/src/Components/inputs/DatePicker.tsx b/client/src/Components/inputs/DatePicker.tsx
index c63c5ce08..3d82a0fa7 100644
--- a/client/src/Components/inputs/DatePicker.tsx
+++ b/client/src/Components/inputs/DatePicker.tsx
@@ -8,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface DatePickerComponentProps extends Omit, "label"> {
fieldLabel?: string;
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
flexDirection: "row-reverse",
},
"& input": {
- minHeight: 34,
+ minHeight: INPUT_BASE_HEIGHT,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
- borderColor: error ? theme.palette.error.main : theme.palette.divider,
+ borderColor: error
+ ? theme.palette.error.main
+ : theme.palette.text.disabled,
},
},
},
diff --git a/client/src/Components/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx
index 0077d2be6..eda59a9d1 100644
--- a/client/src/Components/inputs/Select.tsx
+++ b/client/src/Components/inputs/Select.tsx
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
+import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface SelectInputProps extends Omit, "label"> {
fieldLabel?: string;
@@ -71,7 +72,7 @@ const SelectInputInner = (
/>
)}
sx={{
- height: "34px",
+ height: `${INPUT_BASE_HEIGHT}px`,
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
@@ -83,10 +84,6 @@ const SelectInputInner = (
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
- borderColor: theme.palette.divider,
- },
- "&:hover .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.divider,
},
...props.sx,
}}
diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx
index 230e7650b..5fe644ca6 100644
--- a/client/src/Components/inputs/TextInput.tsx
+++ b/client/src/Components/inputs/TextInput.tsx
@@ -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 { LAYOUT } from "@/Utils/Theme/constants";
+import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit {
fieldLabel?: string;
@@ -22,16 +22,10 @@ export const TextInput = forwardRef(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
- height: props.multiline ? "auto" : 34,
+ height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
fontSize: typographyLevels.base,
overflow: "hidden",
},
- "& .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.divider,
- },
- "&:hover .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.divider,
- },
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
@@ -39,7 +33,7 @@ export const TextInput = forwardRef(function T
},
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
{
- WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
+ WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
caretColor: theme.palette.text.primary,
transition: "background-color 5000s ease-in-out 0s",
diff --git a/client/src/Components/inputs/TimePicker.tsx b/client/src/Components/inputs/TimePicker.tsx
index b82df1f15..46345cfcb 100644
--- a/client/src/Components/inputs/TimePicker.tsx
+++ b/client/src/Components/inputs/TimePicker.tsx
@@ -7,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
interface TimePickerComponentProps extends Omit, "label"> {
fieldLabel?: string;
@@ -33,7 +34,7 @@ export const TimePickerComponent = ({
sx: {
width: "fit-content",
"& input": {
- minHeight: 34,
+ minHeight: INPUT_BASE_HEIGHT,
p: 0,
px: theme.spacing(5),
},
diff --git a/client/src/Components/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx
index a2b392357..7a344c071 100644
--- a/client/src/Components/inputs/ToggleButton.tsx
+++ b/client/src/Components/inputs/ToggleButton.tsx
@@ -4,6 +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 { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
const theme = useTheme();
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
sx={{
px: 8,
textTransform: "none",
- height: 34,
+ height: INPUT_BASE_HEIGHT,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
"&.Mui-selected": {
backgroundColor: selectedBg,
color: theme.palette.text.primary,
- fontWeight: 500,
"&:hover": {
backgroundColor: selectedHoverBg,
},
diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx
index 1dbc65118..3af48efb3 100644
--- a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx
+++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx
@@ -5,9 +5,10 @@ import {
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
+import type { AutocompleteProps } from "@mui/material/Autocomplete";
export interface AutocompleteOption {
- id: string;
+ id: string | number;
name: string;
}
@@ -16,6 +17,9 @@ interface FormAutocompleteProps["filterOptions"];
+ getOptionDisabled?: AutocompleteProps["getOptionDisabled"];
+ disableClearable?: boolean;
}
export const FormAutocompleteField = <
@@ -26,6 +30,9 @@ export const FormAutocompleteField = <
options,
fieldLabel,
placeholder,
+ filterOptions,
+ getOptionDisabled,
+ disableClearable,
}: FormAutocompleteProps) => {
const { control } = useFormContext();
return (
@@ -39,10 +46,14 @@ export const FormAutocompleteField = <
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
+ if (newValue === null && disableClearable) return;
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
+ filterOptions={filterOptions}
+ getOptionDisabled={getOptionDisabled}
+ disableClearable={disableClearable}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
diff --git a/client/src/Components/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx
index 7f6968f0b..c907b96bc 100644
--- a/client/src/Components/monitors/HeaderMonitorControls.tsx
+++ b/client/src/Components/monitors/HeaderMonitorControls.tsx
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
- }
- onClick={async () => {
- await post(`/notifications/test/all`, { monitorId: monitor.id });
- }}
- sx={{
- whiteSpace: "nowrap",
- }}
- >
- {t("common.buttons.testNotifications")}
-
+ {isAdmin && (
+ }
+ onClick={async () => {
+ await post(`/notifications/test/all`, { monitorId: monitor.id });
+ }}
+ sx={{
+ whiteSpace: "nowrap",
+ }}
+ >
+ {t("common.buttons.testNotifications")}
+
+ )}
{
- c.setSearch(event.target.value);
+ controller.setSearch(event.target.value);
}}
/>
- {c.selectedTags.length > 0 && (
+ {controller.selectedTags.length > 0 && (
{t("pages.uptime.filters.activeTags")}
- {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 (
}
- onClick={c.handleBulkResume}
+ onClick={controller.handleBulkResume}
>
{t("common.buttons.resume")}
}
- onClick={c.handleBulkPause}
+ onClick={controller.handleBulkPause}
>
{t("common.buttons.pause")}
@@ -144,12 +144,12 @@ export const MonitorListPage = ({
)}
{children}
);
diff --git a/client/src/Components/monitors/charts/HistogramDockerContainer.tsx b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
index b7a420fc9..fe6175cdd 100644
--- a/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
+++ b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
@@ -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 & {
+ 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 (
+
+ {formatDateWithTz(ts, format, uiTimezone)}
+ {t(labelKey, { value: valueFormatter(dataPoint) })}
+
+ );
+};
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 (
(
+
+ )}
+ />
{
icon: ,
},
{
- name: t("components.sidebar.menu.pagespeed"),
- path: "pagespeed",
- icon: ,
+ name: t("components.sidebar.menu.docker"),
+ path: "docker",
+ icon: ,
},
{
name: t("components.sidebar.menu.infrastructure"),
@@ -42,9 +42,9 @@ export const getMenu = (t: Function) => {
icon: ,
},
{
- name: t("components.sidebar.menu.docker"),
- path: "docker",
- icon: ,
+ name: t("components.sidebar.menu.pagespeed"),
+ path: "pagespeed",
+ icon: ,
},
],
},
diff --git a/client/src/Components/sidebar/NavItem.tsx b/client/src/Components/sidebar/NavItem.tsx
index 24f2c5778..d573c884f 100644
--- a/client/src/Components/sidebar/NavItem.tsx
+++ b/client/src/Components/sidebar/NavItem.tsx
@@ -25,11 +25,10 @@ export const NavItem = ({
const iconStroke = selected
? theme.palette.sidebar.accent
: theme.palette.text.secondary;
-
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
- ? theme.palette.action.selected
- : theme.palette.action.hover;
+ ? theme.palette.action.selectedHover
+ : theme.palette.action.rowHover;
const fontWeight = selected ? 600 : 400;
return (
{
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
+ bgcolor={theme.palette.background.paper}
sx={{
touchAction: "none",
transition: transition,
zIndex: 1000,
- backdropFilter: "blur(8px)",
}}
>
{
+export interface ApiResponse {
success: boolean;
msg: string;
data: T;
diff --git a/client/src/Hooks/UseToast.ts b/client/src/Hooks/UseToast.ts
index a0da495bc..29f340238 100644
--- a/client/src/Hooks/UseToast.ts
+++ b/client/src/Hooks/UseToast.ts
@@ -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 };
+ }, []);
};
diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts
index 5458a75d8..89ed0f066 100644
--- a/client/src/Hooks/useMonitorForm.ts
+++ b/client/src/Hooks/useMonitorForm.ts
@@ -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":
diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx
index 51fea25b6..b3c0f3293 100644
--- a/client/src/Pages/Account/components/HeaderTeamControls.tsx
+++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx
@@ -59,7 +59,12 @@ export const HeaderTeamControls = ({
}
+ startIcon={
+
+ }
onClick={onInviteClick}
>
{t("common.buttons.inviteMember")}
@@ -69,7 +74,12 @@ export const HeaderTeamControls = ({
}
+ startIcon={
+
+ }
onClick={onAddMemberClick}
>
{t("common.buttons.addMember")}
diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx
index 5c2b693e0..921f3deb3 100644
--- a/client/src/Pages/CreateMonitor/index.tsx
+++ b/client/src/Pages/CreateMonitor/index.tsx
@@ -38,6 +38,7 @@ import {
PageSpeedStrategies,
DnsRecordTypes,
MonitorIntervalOptions,
+ MIN_MONITOR_INTERVAL_MS,
DefaultMonitorMatchMethod,
MonitorMatchMethods,
GeoCheckIntervalOptions,
@@ -50,6 +51,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
+ isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -57,15 +59,58 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
+import {
+ FormAutocompleteField,
+ type AutocompleteOption,
+} from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy";
+import { formatDuration } from "@/Utils/TimeUtils";
const httpMethodOptions = HttpMethods.map((method) => ({
value: method,
label: method,
}));
+const PEM_FIELD_ROWS = 6;
+
+// Hides the pasted key while the field is not focused. Ignored by browsers
+// without -webkit-text-security, which then show the key as plain text.
+const maskedInputSx = {
+ "&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
+};
+
+interface IntervalOption extends AutocompleteOption {
+ id: number;
+}
+
+const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
+
+const toIntervalOption = (intervalMs: number): IntervalOption => ({
+ id: intervalMs,
+ name: formatDuration(intervalMs, true),
+});
+
+const filterIntervalOptions = (
+ options: IntervalOption[],
+ inputValue: string
+): IntervalOption[] => {
+ const input = inputValue.trim();
+ if (input === "" || options.some((option) => option.name === input)) {
+ return options;
+ }
+
+ const amount = Number(input);
+ if (!Number.isFinite(amount) || amount <= 0) {
+ return [];
+ }
+
+ return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
+ .filter(Number.isSafeInteger)
+ .map(toIntervalOption);
+};
+
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -118,8 +163,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 +300,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 +310,9 @@ const CreateMonitorPage = () => {
? "pagespeed"
: pageType === "hardware"
? "hardware"
- : "http";
+ : pageType === "docker"
+ ? "docker"
+ : "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet(
isEditMode ? `/monitors/${monitorId}` : null
@@ -297,10 +345,12 @@ 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;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
+ const watchedInterval = watch("interval");
// Steps without an advanced section drop it, so the last step is the form's.
const totalSteps = monitorStepCount(watchedType);
@@ -331,6 +381,8 @@ const CreateMonitorPage = () => {
[watchedType, t]
);
+ const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
+
const { post, loading: isCreating } = usePost();
const { patch, loading: isUpdating } = usePatch();
const isSubmitting = isCreating || isUpdating;
@@ -373,6 +425,8 @@ const CreateMonitorPage = () => {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
+ } else if (pageType === "docker") {
+ navigate("/docker");
} else {
navigate("/uptime");
}
@@ -454,16 +508,16 @@ const CreateMonitorPage = () => {
[t]
);
- const intervalOptions = useMemo(
- () =>
- MonitorIntervalOptions.map((option) => ({
- value: option.value,
- label: t(
- `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
- ),
- })),
- [t]
- );
+ const intervalOptions = useMemo(() => {
+ const options = MonitorIntervalOptions.map(({ value }) => toIntervalOption(value));
+ if (
+ typeof watchedInterval === "number" &&
+ !options.some((option) => option.id === watchedInterval)
+ ) {
+ options.push(toIntervalOption(watchedInterval));
+ }
+ return options;
+ }, [watchedInterval]);
const matchMethodOptions = useMemo(
() =>
@@ -684,17 +738,80 @@ const CreateMonitorPage = () => {
/>
)}
+ {showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
+
+
+
+
+
+
+ {t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
+
+
+ }
+ />
+ )}
+
{showStep(1) && (
+ filterIntervalOptions(options, inputValue)
+ }
+ getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
+ disableClearable
/>
}
/>
@@ -823,6 +940,19 @@ const CreateMonitorPage = () => {
/>
)}
+ {showStep(1) && watchedType === "docker" && (
+
+ }
+ />
+ )}
+
{showStep(2) &&
(watchedType === "http" ||
watchedType === "grpc" ||
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx b/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx
deleted file mode 100644
index e69de29bb..000000000
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx
new file mode 100644
index 000000000..b1ccddc2c
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx
@@ -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 (
+
+
+ {t("pages.docker.container.logs.recentLines", { count: lines })}
+
+
+ {lastUpdatedAt !== null && !loading && (
+
+ )}
+ {renderStatus()}
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx
new file mode 100644
index 000000000..4a022e44e
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx
@@ -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 (
+
+
+
+
+ {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)}
+
+
+
+
+
+ {row.stream}
+
+ : {row.text}
+
+
+
+ {row.gapBefore ? (
+
+
+ {t("pages.docker.container.logs.skipped")}
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx
index ed97917bf..ce92753fc 100644
--- a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx
+++ b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx
@@ -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 (
{
- 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>(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([]);
+ const [olderExhausted, setOlderExhausted] = useState(false);
+ const [loadingInitial, setLoadingInitial] = useState(false);
+ const [loadingOlder, setLoadingOlder] = useState(false);
+ const [lastUpdatedAt, setLastUpdatedAt] = useState(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(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) => {
+ 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 (
+
+ );
+ }
+
+ if (!enabled) {
+ return (
+
+ );
+ }
+
+ if (lastUpdatedAt !== null && rows.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {rows.length > 0 && (
+
+ {olderExhausted ? (
+
+ {t("pages.docker.container.logs.noOlder")}
+
+ ) : (
+
+ )}
+
+ )}
+ {rows.map((row) => {
+ return (
+
+ );
+ })}
+
+
+ );
};
diff --git a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
index 32ad93c2f..395aa00d6 100644
--- a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
+++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
@@ -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}
/>
);
diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx
index b4583e4c1..f394559b6 100644
--- a/client/src/Pages/Docker/ContainerDetails/index.tsx
+++ b/client/src/Pages/Docker/ContainerDetails/index.tsx
@@ -69,8 +69,19 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) =
- {activeTab === 0 && }
- {activeTab === 1 && }
+ {activeTab === 0 && (
+
+ )}
+ {activeTab === 1 && (
+
+ )}
);
};
diff --git a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
index 7b35e6bfe..ea06557f6 100644
--- a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
+++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
@@ -327,11 +327,7 @@ export const DockerMonitorsTable = ({
onRowClick={(row) => {
navigate(`/docker/host/${row.id}`);
}}
- getRowSx={(row) => ({
- backgroundColor: isRowSelected(row.id)
- ? theme.palette.action.selected
- : "inherit",
- })}
+ isRowSelected={(row) => isRowSelected(row.id)}
/>
{
direction="row"
alignItems="center"
gap={theme.spacing(2)}
+ sx={{ "& svg": { color: theme.palette.text.secondary } }}
>
{icon}
{label}
@@ -143,7 +144,10 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
gap: theme.spacing(2),
}}
>
-
+
{
navigate(`/infrastructure/${row.id}`);
}}
- getRowSx={(row) => ({
- backgroundColor: isRowSelected(row.id)
- ? theme.palette.action.selected
- : "inherit",
- })}
+ isRowSelected={(row) => isRowSelected(row.id)}
/>
{
alignItems="center"
>
{
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
+ watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
{
}
/>
)}
+ {watchedType === "signalgrid" && (
+
+
+
+
+
+ }
+ />
+ )}
{watchedType === "twilio" && (
}
+ startIcon={
+
+ }
onClick={onDelete}
>
{translate.t("common.buttons.delete")}
diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
index 93c29d188..1fc25a154 100644
--- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
@@ -77,7 +77,12 @@ export const HeaderStatusPageControls = ({
}
+ startIcon={
+
+ }
onClick={() => navigate(`/status/configure/${statusPage.url}`)}
>
{t("common.buttons.configure")}
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
index 807eed742..a355e8774 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
@@ -211,6 +211,7 @@ export const BaseStatusPage = ({
>
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts
index 2ad63fd07..65bfb3f86 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts
+++ b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts
@@ -1,5 +1,5 @@
export const SANS_STACK =
- '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
index ed32cf7ea..1d98ddc79 100644
--- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
+++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx
@@ -350,11 +350,7 @@ export const MonitorTable = ({
onRowClick={(row) => {
navigate(`/uptime/${row.id}`);
}}
- getRowSx={(row) => ({
- backgroundColor: isRowSelected(row.id)
- ? theme.palette.action.selected
- : "inherit",
- })}
+ isRowSelected={(row) => isRowSelected(row.id)}
/>
{
- 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
>
);
diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts
index 419ff7736..1585b7771 100644
--- a/client/src/Types/Check.ts
+++ b/client/src/Types/Check.ts
@@ -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;
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index a0ed555d4..892e2b549 100644
--- a/client/src/Types/Monitor.ts
+++ b/client/src/Types/Monitor.ts
@@ -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";
@@ -59,6 +60,8 @@ export const monitorTypeLabelKey: Record &
pagespeed: "optionPagespeed",
};
+export const MIN_MONITOR_INTERVAL_MS = 10000;
+
export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" },
@@ -158,6 +161,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 +329,10 @@ export interface DockerContainerResponse {
stats: DockerContainerStats;
}
+export interface DockerContainerLogsResponse {
+ logs: DockerLog[];
+ nextCursor: string | null;
+}
export interface Game {
name: string;
options?: {
diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts
index a118c4a3d..f2c27710b 100644
--- a/client/src/Types/Notification.ts
+++ b/client/src/Types/Notification.ts
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
+ "signalgrid",
"twilio",
"ntfy",
] as const;
diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts
index 3a7778b75..c8bbca168 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -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 = {
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))
+ );
+ });
+};
diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts
index 0d9168f8a..ae3a7d89d 100644
--- a/client/src/Utils/Theme/Palette.ts
+++ b/client/src/Utils/Theme/Palette.ts
@@ -18,6 +18,20 @@ export const colors = {
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
+ ink900: "#101917",
+ ink600: "#55635C",
+ ink400: "#7C8983",
+ surface50: "#F6F8F7",
+ rule200: "#C7D2CC",
+ successMain: "#0D6049",
+ errorMain: "#A02B24",
+ warningMain: "#8C5806",
+ inkDark900: "#ECEFED",
+ inkDark600: "#A3ADA8",
+ ruleDark: "#3A4642",
+ successDark: "#4BC49E",
+ errorDark: "#E8837A",
+ warningDark: "#E0A458",
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
@@ -43,10 +57,31 @@ export const lightPalette = {
action: {
rowHover: alpha(colors.gray900, HOVER.ROW),
controlHover: alpha(colors.gray900, HOVER.CONTROL),
+ selected: alpha(colors.brandGreen, 0.06),
+ selectedHover: alpha(colors.brandGreen, 0.1),
},
secondary: {
main: colors.gray200,
},
+ background: {
+ default: colors.surface50,
+ paper: "#FFFFFF",
+ },
+ text: {
+ primary: colors.ink900,
+ secondary: colors.ink600,
+ disabled: colors.ink400,
+ },
+ divider: colors.rule200,
+ success: {
+ main: colors.successMain,
+ },
+ error: {
+ main: colors.errorMain,
+ },
+ warning: {
+ main: colors.warningMain,
+ },
sidebar: {
accent: colors.brandGreen,
},
@@ -69,10 +104,13 @@ export const lightPalette = {
export const darkPalette = {
primary: {
main: colors.brandGreen,
+ light: colors.brandGreenLight,
},
action: {
rowHover: alpha("#FFFFFF", HOVER.ROW),
controlHover: alpha("#FFFFFF", HOVER.CONTROL),
+ selected: alpha(colors.brandGreenLight, 0.07),
+ selectedHover: alpha(colors.brandGreenLight, 0.11),
},
secondary: {
main: colors.gray700,
@@ -81,6 +119,21 @@ export const darkPalette = {
default: colors.gray900,
paper: colors.gray850,
},
+ text: {
+ primary: colors.inkDark900,
+ secondary: colors.inkDark600,
+ disabled: colors.ink400,
+ },
+ divider: colors.ruleDark,
+ success: {
+ main: colors.successDark,
+ },
+ error: {
+ main: colors.errorDark,
+ },
+ warning: {
+ main: colors.warningDark,
+ },
sidebar: {
accent: colors.brandGreenLight,
},
diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts
index c63929a60..15b0b7c05 100644
--- a/client/src/Utils/Theme/Theme.ts
+++ b/client/src/Utils/Theme/Theme.ts
@@ -16,6 +16,7 @@ declare module "@mui/material/styles" {
interface TypeAction {
rowHover: string;
controlHover: string;
+ selectedHover: string;
}
interface Palette {
sidebar: { accent: string };
@@ -57,7 +58,7 @@ export type PaletteKey = {
[K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never;
}[keyof Theme["palette"]];
-const fontFamilyPrimary = "system-ui, sans-serif";
+const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif';
const fontFamilyMonospace =
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
const shadow =
@@ -85,11 +86,13 @@ export const theme = (mode: string, palette: any) =>
fontSize: typographyLevels.base,
h1: {
fontSize: typographyLevels.xxl,
- fontWeight: 500,
+ fontWeight: 700,
+ letterSpacing: "-0.015em",
},
h2: {
fontSize: typographyLevels.l,
- fontWeight: 400,
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
},
body1: {
fontSize: typographyLevels.m,
@@ -131,7 +134,11 @@ export const theme = (mode: string, palette: any) =>
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-focusVisible": {
- outline: `1px solid ${theme.palette.primary.main}`,
+ outline: `1px solid ${
+ theme.palette.mode === "dark"
+ ? theme.palette.primary.light
+ : theme.palette.primary.main
+ }`,
outlineOffset: 2,
},
}),
@@ -185,18 +192,38 @@ export const theme = (mode: string, palette: any) =>
}),
},
},
-
MuiOutlinedInput: {
styleOverrides: {
+ input: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ height: "100%",
+ },
root: ({ theme }) => ({
- "&:hover .MuiOutlinedInput-notchedOutline": {
+ "& .MuiSelect-select": {
+ paddingTop: 0,
+ paddingBottom: 0,
+ display: "flex",
+ alignItems: "center",
+ minHeight: "unset",
+ },
+ "& .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.divider,
+ transition: "border-color 0.15s ease",
+ },
+ "&:hover:not(.Mui-focused):not(.Mui-disabled) .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: theme.palette.text.disabled,
+ },
+ "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor:
theme.palette.mode === "dark"
- ? "rgba(255, 255, 255, 0.23)"
- : theme.palette.text.primary,
+ ? theme.palette.primary.light
+ : theme.palette.primary.main,
+ borderWidth: 2,
},
- "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
- borderColor: palette.primary.main,
+ "&.Mui-error .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.error.main,
},
}),
},
diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts
index 70ddcd407..950beff49 100644
--- a/client/src/Utils/Theme/constants.ts
+++ b/client/src/Utils/Theme/constants.ts
@@ -18,6 +18,8 @@ export const LAYOUT = {
XXL: 16,
} as const;
+export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
+
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
ROW: 0.025, // Overlay alpha for hoverable rows and cards
diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts
index 00ab3aaff..1637d5297 100644
--- a/client/src/Validation/monitor.ts
+++ b/client/src/Validation/monitor.ts
@@ -6,6 +6,7 @@ import {
HttpMethods,
PageSpeedStrategies,
ProxyModes,
+ MIN_MONITOR_INTERVAL_MS,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -27,7 +28,8 @@ const baseSchema = z.object({
description: z.string().optional(),
interval: z
.number()
- .min(15000, "Interval must be at least 15 seconds")
+ .int("Interval must be a whole number of milliseconds")
+ .min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
.register(monitorStepRegistry, { step: 1 }),
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
@@ -108,10 +110,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 +239,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 +249,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;
diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts
index f7490d526..dd0358b26 100644
--- a/client/src/Validation/notifications.ts
+++ b/client/src/Validation/notifications.ts
@@ -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,
]);
diff --git a/client/src/assets/Images/sushi_404.svg b/client/src/assets/Images/sushi_404.svg
index c90a1058a..3770b5f1c 100644
--- a/client/src/assets/Images/sushi_404.svg
+++ b/client/src/assets/Images/sushi_404.svg
@@ -1,1735 +1,5 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/client/src/index.css b/client/src/index.css
index a662ef475..bba41abe5 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,5 +1,3 @@
-@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
-
* {
box-sizing: border-box;
}
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 03b69a3bf..078722f53 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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",
@@ -633,9 +635,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",
@@ -717,7 +719,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 supported games list 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.",
@@ -757,7 +759,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.",
@@ -815,6 +817,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.",
@@ -824,6 +835,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."
+ }
+ }
}
}
},
@@ -957,6 +991,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": {
@@ -1218,7 +1277,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"
@@ -1300,6 +1359,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
+ "signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1320,6 +1380,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.",
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 1f5d4d2ef..ca81c338c 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -1,5 +1,7 @@
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
+// Self-hosted so the app keeps its typeface on air-gapped installs
+import "@fontsource-variable/geist";
import "./index.css";
import { BrowserRouter as Router } from "react-router-dom";
import { Provider } from "react-redux";
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index e1a2d0e5a..a66bf0bb0 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -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:
diff --git a/docs/hosting/checkmate-so/docker-compose.yaml b/docs/hosting/checkmate-so/docker-compose.yaml
index 96849fecb..f588db87a 100644
--- a/docs/hosting/checkmate-so/docker-compose.yaml
+++ b/docs/hosting/checkmate-so/docker-compose.yaml
@@ -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
diff --git a/server/.env.example b/server/.env.example
index 5a44935b8..b228110e6 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -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=