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/CLAUDE.md b/CLAUDE.md
index 3e601b453..498858e16 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -109,7 +109,7 @@ client/src/
- **Data Fetching**: SWR + Axios
- **Database**: MongoDB with Mongoose ODM
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
-- **i18n**: i18next + react-i18next (translations via PoEditor)
+- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
---
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/actions-menu/index.tsx b/client/src/Components/actions-menu/index.tsx
index 3b2f3688a..2eca19cee 100644
--- a/client/src/Components/actions-menu/index.tsx
+++ b/client/src/Components/actions-menu/index.tsx
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
+import { useTheme } from "@mui/material/styles";
export type ActionMenuItem = {
id: number | string;
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
+ const theme = useTheme();
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
return (
-
+
void;
+ dateRange: DateRange;
+ setDateRange: (dateRange: DateRange) => void;
}
export const HeaderTimeRange = ({
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent,
- newValue: string | null
+ newValue: DateRange | null
) => {
if (newValue !== null) {
setDateRange(newValue);
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/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx
index 46abe5d39..e5233eb0f 100644
--- a/client/src/Components/design-elements/BaseChart.tsx
+++ b/client/src/Components/design-elements/BaseChart.tsx
@@ -10,8 +10,10 @@ import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
icon?: React.ReactNode;
title: string;
+ rightTitle?: string;
width?: number | string;
maxWidth?: number | string;
+ flexBasis?: number | string;
padding?: number | string | ResponsiveStyleValue;
onClick?: () => void;
}>;
@@ -19,8 +21,10 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
title,
+ rightTitle,
width = "100%",
maxWidth = "100%",
+ flexBasis = "0%",
padding,
onClick,
}: BaseChartProps) => {
@@ -32,6 +36,7 @@ export const BaseChart = ({
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
+ flexBasis,
width: width,
maxWidth: maxWidth,
...(onClick && {
@@ -44,13 +49,27 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
-
- {title}
-
+
+ {title}
+
+ {rightTitle && (
+
+ {rightTitle}
+
+ )}
+
{children}
diff --git a/client/src/Components/design-elements/Breadcrumb.tsx b/client/src/Components/design-elements/Breadcrumb.tsx
index ae3e768c2..7ce2560f6 100644
--- a/client/src/Components/design-elements/Breadcrumb.tsx
+++ b/client/src/Components/design-elements/Breadcrumb.tsx
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
+ const thirdSegment = segments[2];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
- const hasSubPage = isActionPage || isDetailsPage;
+ // page/section/:id routes such as docker/host/:monitorId
+ const isSectionDetailsPage = Boolean(
+ secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
+ );
+ const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
const getSubPageLabel = (): string => {
- if (isActionPage) {
+ if (isActionPage || isSectionDetailsPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx
index 4130f67a7..3c0d75dbe 100644
--- a/client/src/Components/design-elements/Gauge.tsx
+++ b/client/src/Components/design-elements/Gauge.tsx
@@ -39,9 +39,9 @@ export const Gauge = forwardRef(function Gauge(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
- strokeLength: (progress / 100) * (2 * Math.PI * radius),
+ strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
}),
- [radius, strokeWidth, progress]
+ [radius, strokeWidth, progressWithinRange]
);
const [offset, setOffset] = useState(circumference);
@@ -52,7 +52,7 @@ export const Gauge = forwardRef(function Gauge(
}, 100);
return () => clearTimeout(timer);
- }, [progress, circumference, strokeLength]);
+ }, [circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
@@ -123,6 +123,7 @@ export const DetailGauge = ({
lowerLabel,
lowerValue,
maxWidth = 225,
+ flexBasis = "0%",
}: {
title: string;
progress: number;
@@ -131,6 +132,7 @@ export const DetailGauge = ({
lowerLabel?: string;
lowerValue?: string | number;
maxWidth?: number;
+ flexBasis?: number | string;
}) => {
const theme = useTheme();
return (
@@ -138,6 +140,7 @@ export const DetailGauge = ({
icon={null}
title={title}
maxWidth={maxWidth}
+ flexBasis={flexBasis}
>
{
+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 954120fe5..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";
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey;
sx?: SxProps;
+ interactive?: boolean;
}>;
-export const GradientBox = ({ children, palette, sx }: GradientBox) => {
+export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
const theme = useTheme();
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 (
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
+ ...(interactive && {
+ cursor: "pointer",
+ "&:hover": {
+ backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
+ },
+ }),
...sx,
}}
>
@@ -62,10 +69,8 @@ export const StatBox = ({
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/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx
index c3fd089d7..63c5b0f35 100644
--- a/client/src/Components/design-elements/StatusLabel.tsx
+++ b/client/src/Components/design-elements/StatusLabel.tsx
@@ -1,12 +1,19 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
+import Stack from "@mui/material/Stack";
import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles";
-import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
+import {
+ getDockerStatePalette,
+ getStatusPalette,
+ getValuePalette,
+} from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
+import type { DockerContainerState } from "@/Types/Check";
+import { SPACING } from "@/Utils/Theme/constants";
export const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number];
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
);
};
+
+export const DockerStateLabel = ({
+ state,
+ name,
+ image,
+}: {
+ state: DockerContainerState;
+ name: string;
+ image: string;
+}) => {
+ const palette = getDockerStatePalette(state);
+ const theme = useTheme();
+
+ return (
+
+
+
+ {name}
+ {image}
+
+
+ );
+};
diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx
index a02f9be8f..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);
@@ -80,6 +82,22 @@ 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) {
return (
(onRowClick ? onRowClick(row) : null)}
+ onClick={(e) => {
+ if (hasTextSelectionWithin(e.currentTarget)) return;
+ if (onRowClick) onRowClick(row);
+ }}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
@@ -108,6 +129,11 @@ export function DataTable<
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default",
+ ...(onRowClick && {
+ "&:hover": {
+ backgroundColor: theme.palette.action.rowHover,
+ },
+ }),
}}
key={key}
>
@@ -191,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)}`,
@@ -227,15 +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);
}}
@@ -292,6 +332,12 @@ interface TablePaginationActionsProps {
onPageChange: (event: React.MouseEvent, newPage: number) => void;
}
+const controlHoverSx = (theme: Theme): SxProps => ({
+ "&:hover": {
+ backgroundColor: theme.palette.action.controlHover,
+ },
+});
+
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
@@ -321,6 +367,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
+ sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
+ sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
+ sx={controlHoverSx(theme)}
>
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
{theme.direction === "rtl" ? (
{
@@ -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/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx
index 196ef2042..d746150ed 100644
--- a/client/src/Components/monitors/ControlsFilter.tsx
+++ b/client/src/Components/monitors/ControlsFilter.tsx
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
-const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
+const types = ["http", "ping", "port", "game", "grpc", "websocket"];
const typeDisplayNames: Record = {
http: "HTTP",
ping: "Ping",
port: "Port",
- docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
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")}
+
+ )}
{
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const isAdmin = useIsAdmin();
+ const isLoading = controller.isLoading || Boolean(extraLoading);
+
+ return (
+
+
+
+
+
+ {
+ controller.setSearch(event.target.value);
+ }}
+ />
+
+ {controller.selectedTags.length > 0 && (
+
+
+ {t("pages.uptime.filters.activeTags")}
+
+ {controller.selectedTags.map((tagId) => {
+ const tag = controller.tags?.find((t) => t.id === tagId);
+ if (!tag) return null;
+ return (
+
+ );
+ })}
+
+ )}
+ {bulkActions && !isLoading && (
+
+ }
+ onClick={controller.handleBulkResume}
+ >
+ {t("common.buttons.resume")}
+
+ }
+ onClick={controller.handleBulkPause}
+ >
+ {t("common.buttons.pause")}
+
+
+ )}
+ {children}
+
+
+ );
+};
diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts
deleted file mode 100644
index 0516eaac3..000000000
--- a/client/src/Components/monitors/charts/ChartUtils.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-export const computeYAxisCap = (values: number[]): number | undefined => {
- const sorted = values.slice().sort((a, b) => a - b);
- if (sorted.length < 2) return undefined;
- const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
- return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
-};
-
-export const PHASE_KEYS = [
- "avgDns",
- "avgTcp",
- "avgTls",
- "avgRequest",
- "avgFirstByte",
- "avgDownload",
-] as const;
-
-export const PHASE_COLOR_KEYS = {
- avgDns: "dns",
- avgTcp: "tcp",
- avgTls: "tls",
- avgRequest: "request",
- avgFirstByte: "firstByte",
- avgDownload: "download",
-} as const;
-
-// i18n keys, resolved with t() at render
-export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
- avgDns: "common.charts.phases.dns",
- avgTcp: "common.charts.phases.tcp",
- avgTls: "common.charts.phases.tls",
- avgRequest: "common.charts.phases.request",
- avgFirstByte: "common.charts.phases.firstByte",
- avgDownload: "common.charts.phases.download",
-};
diff --git a/client/src/Components/monitors/charts/ChartUtils.tsx b/client/src/Components/monitors/charts/ChartUtils.tsx
new file mode 100644
index 000000000..c90fdb6d5
--- /dev/null
+++ b/client/src/Components/monitors/charts/ChartUtils.tsx
@@ -0,0 +1,113 @@
+export const computeYAxisCap = (values: number[]): number | undefined => {
+ const sorted = values.slice().sort((a, b) => a - b);
+ if (sorted.length < 2) return undefined;
+ const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
+ return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
+};
+
+export const PHASE_KEYS = [
+ "avgDns",
+ "avgTcp",
+ "avgTls",
+ "avgRequest",
+ "avgFirstByte",
+ "avgDownload",
+] as const;
+
+export const PHASE_COLOR_KEYS = {
+ avgDns: "dns",
+ avgTcp: "tcp",
+ avgTls: "tls",
+ avgRequest: "request",
+ avgFirstByte: "firstByte",
+ avgDownload: "download",
+} as const;
+
+// i18n keys, resolved with t() at render
+export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
+ avgDns: "common.charts.phases.dns",
+ avgTcp: "common.charts.phases.tcp",
+ avgTls: "common.charts.phases.tls",
+ avgRequest: "common.charts.phases.request",
+ avgFirstByte: "common.charts.phases.firstByte",
+ avgDownload: "common.charts.phases.download",
+};
+
+export const AREA_COLORS = [
+ // Blues
+ "#3182bd", // Deep blue
+ "#6baed6", // Medium blue
+ "#9ecae1", // Light blue
+
+ // Greens
+ "#74c476", // Soft green
+ "#a1d99b", // Light green
+ "#c7e9c0", // Pale green
+
+ // Oranges
+ "#fdae6b", // Warm orange
+ "#fdd0a2", // Light orange
+ "#feedde", // Pale orange
+
+ // Purples
+ "#9467bd", // Lavender
+ "#a55194", // Deep magenta
+ "#c994c7", // Soft magenta
+
+ // Reds
+ "#ff9896", // Soft red
+ "#de2d26", // Deep red
+ "#fc9272", // Medium red
+
+ // Cyans/Teals
+ "#17becf", // Cyan
+ "#7fcdbb", // Teal
+ "#a1dab4", // Light teal
+
+ // Yellows
+ "#fec44f", // Mustard
+ "#fee391", // Light yellow
+ "#ffffd4", // Pale yellow
+
+ // Additional colors
+ "#e377c2", // Soft pink
+ "#bcbd22", // Olive
+ "#2ca02c", // Vibrant green
+];
+
+export const createGradient = ({
+ id,
+ startColor,
+ endColor,
+ startOpacity = 0.8,
+ endOpacity = 0,
+ direction = "vertical",
+}: {
+ id: string;
+ startColor: string;
+ endColor: string;
+ startOpacity?: number;
+ endOpacity?: number;
+ direction?: "vertical" | "horizontal";
+}) => (
+
+
+
+
+
+
+);
diff --git a/client/src/Components/monitors/charts/HistogramDockerContainer.tsx b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
new file mode 100644
index 000000000..fe6175cdd
--- /dev/null
+++ b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx
@@ -0,0 +1,132 @@
+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;
+ stats: DockerContainerStats;
+ dataKey: string;
+ strokeColor: string;
+ gradientStartColor: string;
+ range: DateRange;
+ formatter: (value: number) => string;
+ labelKey: string;
+}
+
+export const HistogramDockerContainer = ({
+ title,
+ rightTitle,
+ stats,
+ dataKey,
+ strokeColor,
+ gradientStartColor,
+ range,
+ formatter,
+ labelKey,
+}: HistogramDockerContainerProps) => {
+ const uniqueId = useId();
+ const theme = useTheme();
+ const gradientId = `gradient-${uniqueId}`;
+ const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
+ return (
+
+
+
+
+
+ {createGradient({
+ id: gradientId,
+ startColor: gradientStartColor,
+ endColor: "transparent",
+ direction: "vertical",
+ })}
+ (
+
+ )}
+ />
+
+
+
+
+
+ );
+};
diff --git a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx
index 8163898aa..bd3151364 100644
--- a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx
+++ b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx
@@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
-
-const AREA_COLORS = [
- // Blues
- "#3182bd", // Deep blue
- "#6baed6", // Medium blue
- "#9ecae1", // Light blue
-
- // Greens
- "#74c476", // Soft green
- "#a1d99b", // Light green
- "#c7e9c0", // Pale green
-
- // Oranges
- "#fdae6b", // Warm orange
- "#fdd0a2", // Light orange
- "#feedde", // Pale orange
-
- // Purples
- "#9467bd", // Lavender
- "#a55194", // Deep magenta
- "#c994c7", // Soft magenta
-
- // Reds
- "#ff9896", // Soft red
- "#de2d26", // Deep red
- "#fc9272", // Medium red
-
- // Cyans/Teals
- "#17becf", // Cyan
- "#7fcdbb", // Teal
- "#a1dab4", // Light teal
-
- // Yellows
- "#fec44f", // Mustard
- "#fee391", // Light yellow
- "#ffffd4", // Pale yellow
-
- // Additional colors
- "#e377c2", // Soft pink
- "#bcbd22", // Olive
- "#2ca02c", // Vibrant green
-];
-
-const createGradient = ({
- id,
- startColor,
- endColor,
- startOpacity = 0.8,
- endOpacity = 0,
- direction = "vertical",
-}: {
- id: string;
- startColor: string;
- endColor: string;
- startOpacity?: number;
- endOpacity?: number;
- direction?: "vertical" | "horizontal";
-}) => (
-
-
-
-
-
-
-);
+import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
export const HistogramInfrastructure = ({
dateRange,
diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx
index 46532706f..cf87eb074 100644
--- a/client/src/Components/monitors/index.tsx
+++ b/client/src/Components/monitors/index.tsx
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
+export * from "./MonitorListPage";
diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx
index 93cd94623..be42fa6e5 100644
--- a/client/src/Components/sidebar/Menu.tsx
+++ b/client/src/Components/sidebar/Menu.tsx
@@ -19,68 +19,94 @@ import {
Users,
Tag,
Waypoints,
+ Container,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
- name: t("components.sidebar.menu.uptime"),
- path: "uptime",
- icon: ,
+ group: t("components.sidebar.menu.groups.monitoring"),
+ items: [
+ {
+ name: t("components.sidebar.menu.uptime"),
+ path: "uptime",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.docker"),
+ path: "docker",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.infrastructure"),
+ path: "infrastructure",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.pagespeed"),
+ path: "pagespeed",
+ icon: ,
+ },
+ ],
},
{
- name: t("components.sidebar.menu.pagespeed"),
- path: "pagespeed",
- icon: ,
+ group: t("components.sidebar.menu.groups.alerting"),
+ items: [
+ {
+ name: t("components.sidebar.menu.incidents"),
+ path: "incidents",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.notifications"),
+ path: "notifications",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.maintenance"),
+ path: "maintenance",
+ icon: ,
+ },
+ ],
},
{
- name: t("components.sidebar.menu.infrastructure"),
- path: "infrastructure",
- icon: ,
+ group: t("components.sidebar.menu.groups.reporting"),
+ items: [
+ {
+ name: t("components.sidebar.menu.statusPages"),
+ path: "status",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.checks"),
+ path: "checks",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.logs"),
+ path: "logs",
+ icon: ,
+ },
+ ],
},
{
- name: t("components.sidebar.menu.notifications"),
- path: "notifications",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.tags"),
- path: "tags",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.proxies"),
- path: "proxies",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.checks"),
- path: "checks",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.incidents"),
- path: "incidents",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.statusPages"),
- path: "status",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.maintenance"),
- path: "maintenance",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.logs"),
- path: "logs",
- icon: ,
- },
- {
- name: t("components.sidebar.menu.settings"),
- icon: ,
- path: "settings",
+ group: t("components.sidebar.menu.groups.configuration"),
+ items: [
+ {
+ name: t("components.sidebar.menu.tags"),
+ path: "tags",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.proxies"),
+ path: "proxies",
+ icon: ,
+ },
+ {
+ name: t("components.sidebar.menu.settings"),
+ path: "settings",
+ 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 (
{
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
- const { width, transition, collapsed } = useSidebar();
+ const { width, transition, transitionTiming, collapsed } = useSidebar();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
@@ -70,11 +73,11 @@ export const Sidebar = () => {
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)",
}}
>
{
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
- {menu.map((item) => {
- const selected = location.pathname.startsWith(`/${item.path}`);
- return (
- handleNavClick(item.path)}
- />
- );
- })}
+ {menu.map((group) => (
+
+
+
+ {group.group}
+
+
+
+ {group.items.map((item) => {
+ const selected = location.pathname.startsWith(`/${item.path}`);
+ return (
+ handleNavClick(item.path)}
+ />
+ );
+ })}
+
+ ))}
diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts
index be54eead8..a07b629f8 100644
--- a/client/src/Features/UI/uiSlice.ts
+++ b/client/src/Features/UI/uiSlice.ts
@@ -2,7 +2,14 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
-type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
+type TableName =
+ | "monitors"
+ | "team"
+ | "maintenance"
+ | "infrastructure"
+ | "logs"
+ | "pagespeed"
+ | "docker";
interface TableState {
rowsPerPage: number;
@@ -14,9 +21,11 @@ interface SidebarState {
interface UIState {
monitors: TableState;
+ pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
+ docker: TableState;
logs: TableState;
sidebar: SidebarState;
mode: ThemeMode;
@@ -37,6 +46,9 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
+ pagespeed: {
+ rowsPerPage: 10,
+ },
team: {
rowsPerPage: 5,
},
@@ -46,6 +58,9 @@ const initialState: UIState = {
infrastructure: {
rowsPerPage: 5,
},
+ docker: {
+ rowsPerPage: 5,
+ },
logs: {
rowsPerPage: 15,
},
diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts
index 543a9f165..343e0b558 100644
--- a/client/src/Hooks/UseApi.ts
+++ b/client/src/Hooks/UseApi.ts
@@ -6,7 +6,7 @@ import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
-interface ApiResponse {
+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/Hooks/useMonitorListController.ts b/client/src/Hooks/useMonitorListController.ts
new file mode 100644
index 000000000..cd6852594
--- /dev/null
+++ b/client/src/Hooks/useMonitorListController.ts
@@ -0,0 +1,209 @@
+import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
+import { useDelete, useGet } from "@/Hooks/UseApi";
+import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
+import useDebounce from "@/Hooks/useDebounce";
+import type { RootState } from "@/store";
+import {
+ type MonitorsWithChecksResponse,
+ SelectableMonitorTypes,
+ type Monitor,
+ type MonitorType,
+} from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
+import { useCallback, useMemo, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+
+interface MonitorListConfig {
+ types: MonitorType[] | "selectable";
+ checksLimit: number;
+ refreshInterval: number;
+ rowsPerPageTable: TableName;
+ rowsPerPageDefault: number;
+ initialSortField?: string; // default ""
+}
+
+export const useMonitorListController = (config: MonitorListConfig) => {
+ const dispatch = useDispatch();
+ const rowsPerPage = useSelector(
+ (state: RootState) =>
+ state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
+ );
+
+ // Filter states
+ const [selectedTypes, setSelectedTypes] = useState([]);
+ const [selectedStatus, setSelectedStatus] = useState("");
+ const [selectedState, setSelectedState] = useState("");
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [page, setPage] = useState(0);
+ const [search, setSearch] = useState("");
+ const [sortField, setSortField] = useState(config.initialSortField ?? "");
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
+ const [selectedMonitor, setSelectedMonitor] = useState(null);
+ const debouncedSearch = useDebounce(search, 300);
+
+ // Convert filter selections to API filter values
+ // Status: pass "up"/"down" directly to the API
+ // State: "active" -> true, "paused" -> false
+ const toFilterStatus = useMemo(() => {
+ if (selectedStatus === "up") return "up";
+ if (selectedStatus === "down") return "down";
+ return undefined;
+ }, [selectedStatus]);
+
+ const toFilterActive = useMemo(() => {
+ if (selectedState === "active") return "true";
+ if (selectedState === "paused") return "false";
+ return undefined;
+ }, [selectedState]);
+
+ // Determine field and filter for the API request
+ // Priority: status > isActive > search > sort
+ const filterLookup = new Map([
+ [toFilterStatus, "status"],
+ [toFilterActive, "isActive"],
+ ]);
+ const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
+ const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
+ const filter = activeFilter?.[0] || debouncedSearch || undefined;
+
+ const effectiveTypes = useMemo(() => {
+ return config.types === "selectable"
+ ? selectedTypes.length > 0
+ ? selectedTypes
+ : [...SelectableMonitorTypes]
+ : config.types;
+ }, [config.types, selectedTypes]);
+
+ // Fetching
+ const monitorsWithChecksUrl = useMemo(() => {
+ const params = new URLSearchParams();
+ effectiveTypes.forEach((type) => params.append("type", type));
+ selectedTags.forEach((tag) => params.append("tags", tag));
+ params.append("limit", String(config.checksLimit));
+ if (page !== undefined) params.append("page", String(page));
+ if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
+ if (filter) params.append("filter", filter);
+ if (field) params.append("field", field);
+ if (sortOrder) params.append("order", sortOrder);
+ return `/monitors/team/with-checks?${params.toString()}`;
+ }, [
+ effectiveTypes,
+ selectedTags,
+ page,
+ rowsPerPage,
+ filter,
+ field,
+ sortOrder,
+ config.checksLimit,
+ ]);
+
+ const {
+ data: monitorsWithChecksData,
+ isLoading,
+ error,
+ refetch,
+ } = useGet(
+ monitorsWithChecksUrl,
+ {},
+ { refreshInterval: config.refreshInterval, keepPreviousData: true }
+ );
+ const { data: tags } = useGet("/tags/team");
+
+ const bulk = useBulkMonitorActions(refetch, page);
+
+ // Delete ops
+ const { deleteFn, loading: isDeleting } = useDelete();
+ const handleConfirmDelete = async () => {
+ if (!selectedMonitor) return;
+ await deleteFn(`/monitors/${selectedMonitor.id}`);
+ setSelectedMonitor(null);
+ refetch();
+ };
+ const handleCancelDelete = () => setSelectedMonitor(null);
+
+ // Handle filters
+ const handleClearFilters = useCallback(() => {
+ setSelectedTypes([]);
+ setSelectedStatus("");
+ setSelectedState("");
+ setSelectedTags([]);
+ setSearch("");
+ }, []);
+
+ const handleSetRowsPerPage = (value: number) => {
+ dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
+ setPage(0);
+ };
+
+ // Check for active filters
+ const hasActiveFilters = Boolean(
+ selectedTypes.length > 0 ||
+ selectedStatus ||
+ selectedState ||
+ selectedTags.length > 0 ||
+ search
+ );
+
+ // Show empty state only when there are truly no monitors (not just filtered out)
+ // If filters are active and count is 0, pass 1 to prevent empty state fallback
+
+ const { monitors, summary, count } = monitorsWithChecksData ?? {
+ monitors: null,
+ summary: null,
+ count: 0,
+ };
+
+ const effectiveTotalCount =
+ hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
+ ? 1
+ : (summary?.totalMonitors ?? 0);
+
+ return {
+ // Data
+ monitors,
+ summary,
+ count,
+ tags,
+ isLoading,
+ error,
+ refetch,
+ effectiveTotalCount,
+ hasActiveFilters,
+
+ // Filter state and setters
+ selectedTypes,
+ setSelectedTypes,
+ selectedStatus,
+ setSelectedStatus,
+ selectedState,
+ setSelectedState,
+ selectedTags,
+ setSelectedTags,
+ search,
+ setSearch,
+ handleClearFilters,
+
+ // Paging and Sorting
+ page,
+ setPage,
+ rowsPerPage,
+ handleSetRowsPerPage,
+ sortField,
+ setSortField,
+ sortOrder,
+ setSortOrder,
+
+ // Delete
+ selectedMonitor,
+ setSelectedMonitor,
+ isDialogOpen: Boolean(selectedMonitor),
+ isDeleting,
+ handleConfirmDelete,
+ handleCancelDelete,
+
+ // Rendered only when config.bulkActions
+ ...bulk,
+ };
+};
+
+export type MonitorListController = ReturnType;
diff --git a/client/src/Hooks/useSidebar.ts b/client/src/Hooks/useSidebar.ts
index 1cf705dd1..0229d8640 100644
--- a/client/src/Hooks/useSidebar.ts
+++ b/client/src/Hooks/useSidebar.ts
@@ -6,7 +6,8 @@ const SIDEBAR_WIDTH_VAR = 250;
const SIDEBAR_COLLAPSED_WIDTH_VAR = 64;
// Transition timing for sidebar width changes
-const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
+const SIDEBAR_TRANSITION_TIMING = "650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
+const SIDEBAR_TRANSITION = `width ${SIDEBAR_TRANSITION_TIMING}`;
export const useSidebar = () => {
const collapsed = useSelector(
@@ -19,6 +20,7 @@ export const useSidebar = () => {
expandedWidth: SIDEBAR_WIDTH_VAR,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION,
+ transitionTiming: SIDEBAR_TRANSITION_TIMING,
};
};
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/Checks/index.tsx b/client/src/Pages/Checks/index.tsx
index 93364af44..453660413 100644
--- a/client/src/Pages/Checks/index.tsx
+++ b/client/src/Pages/Checks/index.tsx
@@ -17,13 +17,14 @@ import { useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { ChecksSummary, ChecksResponse } from "@/Types/Check";
+import type { DateRange } from "@/Types/Query";
const Checks = () => {
const { t } = useTranslation();
const { monitorId } = useParams<{ monitorId?: string }>();
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [statusFilter, setStatusFilter] = useState("down");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx
index 5c2b693e0..3291ab090 100644
--- a/client/src/Pages/CreateMonitor/index.tsx
+++ b/client/src/Pages/CreateMonitor/index.tsx
@@ -50,6 +50,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
+ isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -66,6 +67,14 @@ const httpMethodOptions = HttpMethods.map((method) => ({
label: method,
}));
+const PEM_FIELD_ROWS = 6;
+
+// Hides the pasted key while the field is not focused. Ignored by browsers
+// without -webkit-text-security, which then show the key as plain text.
+const maskedInputSx = {
+ "&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
+};
+
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -118,8 +127,8 @@ const getGeneralSettingsConfig = (
showDnsRecordType: false,
},
docker: {
- urlLabel: t("pages.createMonitor.form.general.option.container.label"),
- urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"),
+ urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"),
+ urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showProxy: false,
@@ -255,6 +264,7 @@ const CreateMonitorPage = () => {
const firstSegment = pathSegments[0];
if (firstSegment === "pagespeed") return "pagespeed";
if (firstSegment === "infrastructure") return "hardware";
+ if (firstSegment === "docker") return "docker";
return "uptime";
}, [location.pathname]);
@@ -264,7 +274,9 @@ const CreateMonitorPage = () => {
? "pagespeed"
: pageType === "hardware"
? "hardware"
- : "http";
+ : pageType === "docker"
+ ? "docker"
+ : "http";
const { data: existingMonitor, refetch: refetchMonitor } = useGet(
isEditMode ? `/monitors/${monitorId}` : null
@@ -297,6 +309,7 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
+ const watchedUrl = watch("url") as string;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
@@ -331,6 +344,8 @@ const CreateMonitorPage = () => {
[watchedType, t]
);
+ const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
+
const { post, loading: isCreating } = usePost();
const { patch, loading: isUpdating } = usePatch();
const isSubmitting = isCreating || isUpdating;
@@ -373,6 +388,8 @@ const CreateMonitorPage = () => {
navigate("/pagespeed");
} else if (pageType === "hardware") {
navigate("/infrastructure");
+ } else if (pageType === "docker") {
+ navigate("/docker");
} else {
navigate("/uptime");
}
@@ -684,6 +701,64 @@ const CreateMonitorPage = () => {
/>
)}
+ {showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
+
+
+
+
+
+
+ {t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
+
+
+ }
+ />
+ )}
+
{showStep(1) && (
{
/>
)}
+ {showStep(1) && watchedType === "docker" && (
+
+ }
+ />
+ )}
+
{showStep(2) &&
(watchedType === "http" ||
watchedType === "grpc" ||
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
new file mode 100644
index 000000000..ce92753fc
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx
@@ -0,0 +1,54 @@
+import Stack from "@mui/material/Stack";
+import { StatBox } from "@/Components/design-elements";
+
+// Types
+
+// Hooks
+import { useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import type { DockerContainerInfo } from "@/Types/Check";
+
+// Utils
+import { formatDuration } from "@/Utils/TimeUtils";
+import { getDockerPalette } from "@/Utils/MonitorUtils";
+
+export const DockerContainerStatusBoxes = ({
+ container,
+}: {
+ container: DockerContainerInfo | undefined;
+}) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ if (!container) return null;
+
+ const { state, restartCount, health } = container;
+ const startedAt = container.startedAt ?? "0";
+ const runningFor = Date.now() - new Date(startedAt).getTime();
+ const palette = getDockerPalette(state);
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx
new file mode 100644
index 000000000..792234c52
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx
@@ -0,0 +1,252 @@
+// 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")}
+
+ ) : (
+
+ {loadingOlder
+ ? t("pages.docker.container.logs.loadingOlder")
+ : t("pages.docker.container.logs.loadOlder")}
+
+ )}
+
+ )}
+ {rows.map((row) => {
+ return (
+
+ );
+ })}
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
new file mode 100644
index 000000000..395aa00d6
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx
@@ -0,0 +1,126 @@
+// Components
+import Grid from "@mui/material/Grid";
+import Stack from "@mui/material/Stack";
+import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer";
+
+// Hooks
+import { Typography, useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import useMediaQuery from "@mui/material/useMediaQuery";
+
+// Types
+import type { Theme } from "@mui/material";
+import type { TFunction } from "i18next";
+import type { DockerContainerStats } from "@/Types/Monitor";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { formatPercentage } from "@/Utils/FormatUtils";
+import prettyBytes from "pretty-bytes";
+import { BaseBox } from "@/Components/design-elements";
+import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
+import type { DateRange } from "@/Types/Query";
+
+const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
+ {
+ title: t("common.charts.labels.cpuUsage"),
+ key: "avgCpuPct",
+ color: theme.palette.success.main,
+ rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
+ formatter: formatPercentage,
+ labelKey: "common.charts.histogram.avgCpuUsage",
+ },
+ {
+ title: t("common.charts.labels.memoryUsage"),
+ key: "avgMemoryUsedBytes",
+ color: theme.palette.primary.main,
+ rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
+ formatter: prettyBytes,
+ labelKey: "common.charts.histogram.avgMemoryUsage",
+ },
+];
+
+export const TabOverview = ({
+ stats,
+ range,
+}: {
+ stats: DockerContainerStats;
+ range: DateRange;
+}) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const { ports, mounts } = stats?.latest?.container ?? {};
+ const dedupedPorts = dedupeDockerPorts(ports ?? []);
+ return (
+
+ {getChartConfigs(theme, t, stats).map((config) => {
+ return (
+
+
+
+ );
+ })}
+
+
+
+ Ports
+
+
+ {dedupedPorts?.map((port) => {
+ return (
+ {`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}
+ );
+ })}
+
+
+
+
+
+
+ Volumes
+
+
+ {mounts?.map((mount) => {
+ return (
+
+ {`${getDockerMountLabel(mount)} -> ${mount.destination}`}
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx
new file mode 100644
index 000000000..f394559b6
--- /dev/null
+++ b/client/src/Pages/Docker/ContainerDetails/index.tsx
@@ -0,0 +1,89 @@
+// Components
+import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements";
+import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview";
+import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs";
+
+// Hooks
+import { useParams } from "react-router-dom";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useGet } from "@/Hooks/UseApi";
+import type { DockerContainerResponse } from "@/Types/Monitor";
+import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes";
+import type { DateRange } from "@/Types/Query";
+import { HeaderTimeRange } from "@/Components/common";
+interface DockerContainerDetailsProps {
+ open?: "overview" | "logs";
+}
+
+const TAB_MAP = {
+ overview: 0,
+ logs: 1,
+} as const;
+
+const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
+ const [activeTab, setActiveTab] = useState(TAB_MAP[open]);
+ const [dateRange, setDateRange] = useState("recent");
+ const { monitorId, containerName } = useParams<{
+ monitorId: string;
+ containerName: string;
+ }>();
+ const { t } = useTranslation();
+
+ const containerDetailsUrl = monitorId
+ ? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}`
+ : null;
+
+ const { data: containerData } = useGet(
+ containerDetailsUrl,
+ {},
+ { refreshInterval: 10000, keepPreviousData: true }
+ );
+
+ const monitor = containerData?.monitor;
+ if (!monitor) return null;
+
+ const stats = containerData.stats;
+
+ const container = stats?.latest?.container;
+ if (!container) return null;
+
+ const { state, name, image } = container;
+
+ return (
+
+
+
+
+ setActiveTab(newValue)}
+ >
+
+
+
+ {activeTab === 0 && (
+
+ )}
+ {activeTab === 1 && (
+
+ )}
+
+ );
+};
+
+export default DockerDetailsPage;
diff --git a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx
new file mode 100644
index 000000000..72d828f45
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx
@@ -0,0 +1,123 @@
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import { DockerStateLabel, Table } from "@/Components/design-elements";
+// Hooks
+import { useTranslation } from "react-i18next";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+
+// Types
+import type { Header } from "@/Components/design-elements/Table";
+import type { DockerContainerInfo } from "@/Types/Check";
+
+// Utils
+import { formatPercentage } from "@/Utils/FormatUtils";
+
+interface DockerContainersTableProps {
+ monitorId: string | undefined;
+ containers: DockerContainerInfo[];
+ onRowClick?: (container: DockerContainerInfo) => void;
+}
+
+export const DockerContainersTable = ({
+ monitorId,
+ containers,
+}: DockerContainersTableProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const navigate = useNavigate();
+
+ const getHeaders = () => {
+ // const renderSortIcon = (isActive: boolean) => (
+ //
+ // {isActive ? (
+ // sortOrder === "asc" ? (
+ //
+ // ) : (
+ //
+ // )
+ // ) : null}
+ //
+ // );
+ const headers: Header[] = [
+ {
+ id: "container",
+ content: t("pages.docker.host.table.headers.container"),
+ render: (row) => {
+ return (
+
+ );
+ },
+ },
+ {
+ id: "status",
+ content: t("common.table.headers.status"),
+ render: (row) => {
+ return {row.state} ;
+ },
+ },
+ {
+ id: "health",
+ content: t("common.labels.health"),
+ render: (row) => {
+ return {row.health} ;
+ },
+ },
+ {
+ id: "cpu",
+ content: t("pages.docker.host.table.headers.cpu"),
+ render: (row) => {
+ return {formatPercentage(row.cpuPct ?? 0)} ;
+ },
+ },
+ {
+ id: "memory",
+ content: t("pages.docker.host.table.headers.memory"),
+ render: (row) => {
+ return {formatPercentage(row.memoryPct ?? 0)} ;
+ },
+ },
+ {
+ id: "restarts",
+ content: t("common.labels.restarts"),
+ render: (row) => {
+ return {row.restartCount} ;
+ },
+ },
+ ];
+ return headers;
+ };
+
+ let headers = getHeaders();
+
+ if (isSmall) {
+ headers = headers.filter((h) => h.id !== "histogram");
+ }
+
+ return (
+
+ {
+ navigate(`/docker/host/${monitorId}/container/${row.name}`);
+ }}
+ // getRowSx={(row) => ({
+ // backgroundColor: isRowSelected(row.id)
+ // ? theme.palette.action.selected
+ // : "inherit",
+ // })}
+ />
+
+ );
+};
diff --git a/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx
new file mode 100644
index 000000000..1f65836b0
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx
@@ -0,0 +1,47 @@
+import Stack from "@mui/material/Stack";
+import { StatBox } from "@/Components/design-elements";
+
+// Types
+import type { DockerStats } from "@/Types/Monitor";
+
+// Hooks
+import { useTheme } from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+// Utils
+
+export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+
+ if (!stats) return null;
+
+ const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {};
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/HostDetails/index.tsx b/client/src/Pages/Docker/HostDetails/index.tsx
new file mode 100644
index 000000000..7c2d3c80c
--- /dev/null
+++ b/client/src/Pages/Docker/HostDetails/index.tsx
@@ -0,0 +1,55 @@
+import { BasePage } from "@/Components/design-elements";
+import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors";
+import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes";
+
+// Types
+import type { DockerDetailsResponse } from "@/Types/Monitor";
+
+// Hooks
+import { useGet } from "@/Hooks/UseApi";
+import { useIsAdmin } from "@/Hooks/useIsAdmin";
+import { useParams } from "react-router-dom";
+import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable";
+
+const DockerHostDetailsPage = () => {
+ const { monitorId } = useParams<{ monitorId: string }>();
+ const isAdmin = useIsAdmin();
+
+ // The containers list renders the latest check only, so no date range is
+ // offered; the endpoint's dateRange param only shapes the aggregate buckets.
+ const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null;
+
+ const { data: monitorDetailsData, refetch: refetchMonitor } =
+ useGet(
+ monitorDetailsUrl,
+ {},
+ { refreshInterval: 10000, keepPreviousData: true }
+ );
+
+ const monitor = monitorDetailsData?.monitor;
+ const monitorStats = monitorDetailsData?.monitorStats ?? null;
+ const stats = monitorDetailsData?.stats;
+ const containers = stats?.latest?.containers ?? [];
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default DockerHostDetailsPage;
diff --git a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
new file mode 100644
index 000000000..ea06557f6
--- /dev/null
+++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx
@@ -0,0 +1,343 @@
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import {
+ Table,
+ Pagination,
+ StatusLabel,
+ ColoredLabel,
+} from "@/Components/design-elements";
+import { SPACING } from "@/Utils/Theme/constants";
+import type { Header } from "@/Components/design-elements/Table";
+import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
+import { ArrowUp, ArrowDown } from "lucide-react";
+
+import { useTranslation } from "react-i18next";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useTheme } from "@mui/material/styles";
+import { useNavigate } from "react-router-dom";
+import { usePost } from "@/Hooks/UseApi";
+
+import type { Monitor } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
+import type { DockerContainerSummary } from "@/Types/Check";
+import { Checkbox } from "@/Components/inputs";
+
+const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => {
+ const recentChecks = monitor.recentChecks ?? [];
+ return recentChecks[recentChecks.length - 1]?.containerSummary;
+};
+
+interface DockerMonitorsTableProps {
+ monitors: Monitor[];
+ tags: Tag[];
+ refetch: () => void;
+ setSelectedMonitor: (monitor: Monitor | null) => void;
+ sortField: string;
+ setSortField: (field: string) => void;
+ sortOrder: "asc" | "desc";
+ setSortOrder: (order: "asc" | "desc") => void;
+ count: number;
+ page: number;
+ setPage: (page: number) => void;
+ rowsPerPage: number;
+ setRowsPerPage: (rowsPerPage: number) => void;
+ selectedRows?: string[];
+ onSelectionChange?: (selected: string[]) => void;
+}
+
+export const DockerMonitorsTable = ({
+ monitors,
+ tags,
+ refetch,
+ setSelectedMonitor,
+ sortField,
+ setSortField,
+ sortOrder,
+ setSortOrder,
+ count,
+ page,
+ setPage,
+ rowsPerPage,
+ setRowsPerPage,
+ selectedRows = [],
+ onSelectionChange,
+}: DockerMonitorsTableProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const navigate = useNavigate();
+ const { post } = usePost, Monitor>();
+
+ const selectedSet = new Set(selectedRows);
+ const isAllSelected =
+ monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
+ const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
+
+ const handleSelectAll = (checked: boolean) => {
+ onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
+ };
+
+ const handleSelectRow = (id: string, checked: boolean) => {
+ if (!onSelectionChange) return;
+ onSelectionChange(
+ checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
+ );
+ };
+
+ const isRowSelected = (id: string) => selectedSet.has(id);
+
+ const handlePageChange = (
+ _e: React.MouseEvent | null,
+ newPage: number
+ ) => {
+ setPage(newPage);
+ };
+
+ const handleRowsPerPageChange = (
+ e: React.ChangeEvent
+ ) => {
+ const value = Number(e.target.value);
+ setPage(0);
+ setRowsPerPage(value);
+ };
+
+ const handleSort = (e: React.MouseEvent, field: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (sortField === field) {
+ const newOrder = sortOrder === "asc" ? "desc" : "asc";
+ setSortOrder(newOrder);
+ } else {
+ setSortField(field);
+ setSortOrder("asc");
+ }
+ refetch();
+ };
+
+ const getActions = (monitor: Monitor): ActionMenuItem[] => {
+ return [
+ {
+ id: 2,
+ label: t("pages.common.monitors.actions.details"),
+ action: () => {
+ navigate(`${monitor.id}`);
+ },
+ },
+ {
+ id: 3,
+ label: t("pages.common.monitors.actions.incidents"),
+ action: () => {
+ navigate(`/incidents?monitorId=${monitor.id}`);
+ },
+ },
+ {
+ id: 4,
+ label: t("pages.common.monitors.actions.configure"),
+ action: () => {
+ navigate(`/docker/configure/${monitor.id}`);
+ },
+ },
+ {
+ id: 6,
+ label:
+ monitor.isActive === false
+ ? t("common.buttons.resume")
+ : t("common.buttons.pause"),
+ action: async () => {
+ await post(`/monitors/pause/${monitor.id}`, {});
+ refetch();
+ },
+ closeMenu: true,
+ },
+ {
+ id: 7,
+ label: (
+
+ {t("common.buttons.delete")}
+
+ ),
+ action: () => {
+ setSelectedMonitor(monitor);
+ },
+ closeMenu: true,
+ },
+ ];
+ };
+
+ const getHeaders = () => {
+ const renderSortIcon = (isActive: boolean) => (
+
+ {isActive ? (
+ sortOrder === "asc" ? (
+
+ ) : (
+
+ )
+ ) : null}
+
+ );
+ const headers: Header[] = [
+ {
+ id: "selection",
+ content: (
+ handleSelectAll(e.target.checked)}
+ />
+ ),
+ hideMobileLabel: true,
+ render: (row) => (
+ {
+ handleSelectRow(row.id, e.target.checked);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ),
+ onClick: (e) => e?.stopPropagation(),
+ },
+ {
+ id: "name",
+ align: "left",
+ mobileLabel: t("pages.docker.table.headers.host"),
+ content: (
+ handleSort(e, "name")}
+ sx={{ cursor: "pointer" }}
+ >
+ {t("pages.docker.table.headers.host")}
+ {renderSortIcon(sortField === "name")}
+
+ ),
+ render: (row) => {
+ return (
+
+ {row.name}
+
+ {row.url}
+
+
+ );
+ },
+ },
+ {
+ id: "status",
+ mobileLabel: t("common.table.headers.status"),
+ content: (
+ handleSort(e, "status")}
+ sx={{ cursor: "pointer" }}
+ >
+
+ {t("common.table.headers.status")}
+ {renderSortIcon(sortField === "status")}
+
+ ),
+ render: (row) => {
+ return ;
+ },
+ },
+ {
+ id: "containers",
+ content: t("pages.docker.table.headers.containers"),
+ render: (row) => {
+ const summary = summaryOf(row);
+ return (
+
+ {summary
+ ? t("pages.docker.table.containersRunning", {
+ running: summary.running,
+ total: summary.total,
+ })
+ : "—"}
+
+ );
+ },
+ },
+ {
+ id: "tags",
+ content: t("common.table.headers.tags"),
+ render: (row) => {
+ if (row.tags.length === 0) return "-";
+ return (
+
+ {row.tags.map((tagId) => {
+ const fullTag = tags.find((t) => t.id === tagId);
+ if (!fullTag) return null;
+ return (
+
+ );
+ })}
+
+ );
+ },
+ },
+
+ {
+ id: "actions",
+ content: t("common.table.headers.actions"),
+ render: (row) => {
+ return ;
+ },
+ },
+ ];
+ return headers;
+ };
+
+ let headers = getHeaders();
+
+ if (isSmall) {
+ headers = headers.filter((h) => h.id !== "histogram");
+ }
+ return (
+
+ {
+ navigate(`/docker/host/${row.id}`);
+ }}
+ isRowSelected={(row) => isRowSelected(row.id)}
+ />
+
+
+ );
+};
diff --git a/client/src/Pages/Docker/Monitors/index.tsx b/client/src/Pages/Docker/Monitors/index.tsx
new file mode 100644
index 000000000..96d13abbf
--- /dev/null
+++ b/client/src/Pages/Docker/Monitors/index.tsx
@@ -0,0 +1,44 @@
+import { MonitorListPage } from "@/Components/monitors";
+import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
+
+const DockerMonitorsPage = () => {
+ const c = useMonitorListController({
+ types: ["docker"],
+ checksLimit: 25,
+ refreshInterval: 5000,
+ rowsPerPageTable: "docker",
+ rowsPerPageDefault: 10,
+ });
+
+ return (
+
+
+
+ );
+};
+
+export default DockerMonitorsPage;
diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx
index 60dadaa74..75c9672a0 100644
--- a/client/src/Pages/Incidents/Components/CardSummary.tsx
+++ b/client/src/Pages/Incidents/Components/CardSummary.tsx
@@ -30,6 +30,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
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),
}}
>
-
+
{
@@ -30,7 +31,7 @@ const IncidentsPage = () => {
// Filter state
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [activeIncidentsPage, setActiveIncidentsPage] = useState(0);
diff --git a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx
index 4309feca7..4ef5fa145 100644
--- a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx
@@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
return [
{
- title: t("pages.infrastructure.charts.labels.memory"),
+ title: t("common.charts.labels.memoryUsage"),
type: "memory",
dataKeys: ["avgMemoryUsage"],
strokeColor: theme.palette.primary.main,
@@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => {
idx: null,
},
{
- title: t("pages.infrastructure.charts.labels.cpu"),
+ title: t("common.charts.labels.cpuUsage"),
type: "cpu",
dataKeys: ["avgCpuUsage"],
strokeColor: theme.palette.success.main,
diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
index c84a327fa..b302b939d 100644
--- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
@@ -63,19 +63,42 @@ const getChartConfigs = (
return configs;
};
-// Loopback / bridge names Capture sees when it can only observe the container's
-// own network namespace. When ALL reported interfaces match this set we assume
-// the "no real interfaces visible" case and surface the notice.
+// Loopback / bridge names Capture reports when it can only observe the
+// container's own network namespace.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
+// Below this, an interface is carrying no traffic worth charting. Real host
+// NICs sit orders of magnitude above it; a container's veth sits at or near
+// zero.
+const IDLE_INTERFACE_BYTES_PER_SECOND = 1024;
+
+const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean =>
+ checks.every((check) => {
+ const iface = check.net?.find((candidate) => candidate.name === name);
+ if (!iface) {
+ return true;
+ }
+ return (
+ (iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND &&
+ (iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND
+ );
+ });
+
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
- if (named.size === 0) return false;
- for (const name of named) {
- if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
+ if (named.size === 0) {
+ return false;
}
- return true;
+
+ const names = [...named];
+ if (names.every((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
+ return true;
+ }
+ if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) {
+ return false;
+ }
+ return names.every((name) => interfaceIsIdle(checks, name));
};
export const InfraNetworkCharts = ({
diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx
index e2b053aef..eba54070d 100644
--- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx
@@ -8,6 +8,8 @@ import { useTheme } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { CheckSnapshot } from "@/Types/Check";
+const GAUGE_MAX_WIDTH = 260;
+
export const InfraDetailsGauges = ({
snapshot,
}: {
@@ -26,37 +28,42 @@ export const InfraDetailsGauges = ({
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"stretch"}
+ flexWrap={"wrap"}
+ useFlexGap
>
{snapshot?.disk?.map((disk, idx) => {
return (
);
})}
diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx
index 202887ad5..ad1d4a334 100644
--- a/client/src/Pages/Infrastructure/Details/index.tsx
+++ b/client/src/Pages/Infrastructure/Details/index.tsx
@@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi";
import type { HardwareDetailsResponse } from "@/Types/Monitor";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useTranslation } from "react-i18next";
+import type { DateRange } from "@/Types/Query";
const InfrastructureDetails = () => {
const { t } = useTranslation();
@@ -17,7 +18,7 @@ const InfrastructureDetails = () => {
const { monitorId } = useParams<{ monitorId: string }>();
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [selectedTab, setSelectedTab] = useState(0);
const monitorDetailsUrl = useMemo(() => {
diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
index 7c3c003f4..567fd3f9e 100644
--- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
+++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
@@ -331,11 +331,7 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
- getRowSx={(row) => ({
- backgroundColor: isRowSelected(row.id)
- ? theme.palette.action.selected
- : "inherit",
- })}
+ isRowSelected={(row) => isRowSelected(row.id)}
/>
{
- const { t } = useTranslation();
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- const isAdmin = useIsAdmin();
- const dispatch = useDispatch();
-
- // Redux state
- const rowsPerPage = useSelector(
- (state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
- );
-
- // Local state
- const [selectedStatus, setSelectedStatus] = useState("");
- const [selectedState, setSelectedState] = useState("");
- const [selectedTags, setSelectedTags] = useState([]);
- const [search, setSearch] = useState("");
- const [page, setPage] = useState(0);
- const [sortField, setSortField] = useState("");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const [selectedMonitor, setSelectedMonitor] = useState(null);
-
- const { data: tags } = useGet("/tags/team");
-
- const isDialogOpen = Boolean(selectedMonitor);
-
- const debouncedSearch = useDebounce(search, 300);
-
- const handleClearFilters = useCallback(() => {
- setSelectedStatus("");
- setSelectedState("");
- setSelectedTags([]);
- setSearch("");
- }, []);
-
- // Convert filter selections to API filter values
- const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "true";
- if (selectedStatus === "down") return "false";
- return undefined;
- }, [selectedStatus]);
-
- const toFilterActive = useMemo(() => {
- if (selectedState === "active") return "true";
- if (selectedState === "paused") return "false";
- return undefined;
- }, [selectedState]);
-
- // Determine field and filter for the API request
- // Priority: status > isActive > search
- const filterLookup = new Map([
- [toFilterStatus, "status"],
- [toFilterActive, "isActive"],
- ]);
- const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
- const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
- const filter = activeFilter?.[0] || debouncedSearch || undefined;
-
- // Build URL for monitors with checks
- const monitorsWithChecksUrl = useMemo(() => {
- const params = new URLSearchParams();
- params.append("type", "hardware");
- selectedTags.forEach((tagId) => params.append("tags", tagId));
- params.append("limit", "1");
- if (page !== undefined) params.append("page", String(page));
- if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (filter) params.append("filter", filter);
- if (field) params.append("field", field);
- if (sortOrder) params.append("order", sortOrder);
- return `/monitors/team/with-checks?${params.toString()}`;
- }, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
-
- const {
- data: monitorsWithChecksData,
- isLoading: monitorsWithChecksLoading,
- error: monitorsWithChecksError,
- refetch,
- } = useGet(
- monitorsWithChecksUrl,
- {},
- { refreshInterval: 5000, keepPreviousData: true }
- );
-
- const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
- const isLoading = monitorsWithChecksLoading;
-
- // Bulk actions
- const {
- selectedRows,
- setSelectedRows,
- handleBulkPause,
- handleBulkResume,
- handleCancelSelection,
- } = useBulkMonitorActions(refetch, page);
-
- // Check if any filters are active
- const hasActiveFilters = Boolean(
- selectedStatus || selectedState || selectedTags.length > 0 || search
- );
-
- // Show empty state only when there are truly no monitors (not just filtered out)
- const effectiveTotalCount =
- hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
- ? 1
- : (summary?.totalMonitors ?? 0);
-
- // Delete hook
- const { deleteFn, loading: isDeleting } = useDelete();
-
- const handleConfirm = async () => {
- if (!selectedMonitor) return;
- await deleteFn(`/monitors/${selectedMonitor.id}`);
- setSelectedMonitor(null);
- refetch();
- };
-
- const handleCancel = () => {
- setSelectedMonitor(null);
- };
-
+ const c = useMonitorListController({
+ types: ["hardware"],
+ checksLimit: 1,
+ refreshInterval: 5000,
+ rowsPerPageTable: "infrastructure",
+ rowsPerPageDefault: 5,
+ });
return (
-
-
-
-
-
- {
- setSearch(event.target.value);
- }}
- />
-
-
- {selectedTags.length > 0 && (
-
-
- {t("pages.uptime.filters.activeTags")}
-
- {selectedTags.map((tagId) => {
- const tag = tags?.find((t) => t.id === tagId);
- if (!tag) return null;
- return (
-
- );
- })}
-
- )}
-
- {!isLoading && (
-
- }
- onClick={handleBulkResume}
- >
- {t("common.buttons.resume")}
-
- }
- onClick={handleBulkPause}
- >
- {t("common.buttons.pause")}
-
-
- )}
-
{
- dispatch(
- setRowsPerPage({
- value,
- table: "infrastructure",
- })
- );
- setPage(0);
- }}
- selectedRows={selectedRows}
- onSelectionChange={setSelectedRows}
+ monitors={c.monitors || []}
+ tags={c.tags || []}
+ refetch={c.refetch}
+ setSelectedMonitor={c.setSelectedMonitor}
+ count={c.count || 0}
+ page={c.page}
+ setPage={c.setPage}
+ rowsPerPage={c.rowsPerPage}
+ sortField={c.sortField}
+ setSortField={c.setSortField}
+ sortOrder={c.sortOrder}
+ setSortOrder={c.setSortOrder}
+ setRowsPerPage={c.handleSetRowsPerPage}
+ selectedRows={c.selectedRows}
+ onSelectionChange={c.setSelectedRows}
/>
-
-
+
);
};
diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx
index 0372fe749..6445e6e89 100644
--- a/client/src/Pages/Maintenance/create/index.tsx
+++ b/client/src/Pages/Maintenance/create/index.tsx
@@ -1,7 +1,8 @@
import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
-import { BasePage, ConfigBox } from "@/Components/design-elements";
+import { BasePage, ColoredLabel, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { useTheme } from "@mui/material";
@@ -15,6 +16,7 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
import { useForm, FormProvider } from "react-hook-form";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -41,6 +43,7 @@ const CreateMaintenanceWindowPage = () => {
);
const { data: monitors } = useGet("/monitors/team");
+ const { data: tags } = useGet("/tags/team");
const { post, loading: isPosting } = usePost();
const { patch, loading: isPatching } = usePatch();
@@ -203,6 +206,26 @@ const CreateMaintenanceWindowPage = () => {
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
+ renderOptionContent={(monitor) => (
+
+ {monitor.name}
+ {monitor.tags.map((tagId) => {
+ const tag = tags?.find(({ id }) => id === tagId);
+ return tag ? (
+
+ ) : null;
+ })}
+
+ )}
/>
}
/>
diff --git a/client/src/Pages/NotFound/index.tsx b/client/src/Pages/NotFound/index.tsx
index ce974a2e1..edc57028b 100644
--- a/client/src/Pages/NotFound/index.tsx
+++ b/client/src/Pages/NotFound/index.tsx
@@ -1,4 +1,4 @@
-import NotFoundSvg from "@/assets/Images/sushi_404.svg";
+import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
import { Button } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
alignItems="center"
>
{
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
+ watchedType !== "signalgrid" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
{
}
/>
)}
+ {watchedType === "signalgrid" && (
+
+
+
+
+
+ }
+ />
+ )}
{watchedType === "twilio" && (
{
const { monitorId } = useParams();
const isAdmin = useIsAdmin();
const theme = useTheme();
- const [dateRange, setDateRange] = useState("day");
+ const [dateRange, setDateRange] = useState("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx
index b9d0b49b1..3eb597fa6 100644
--- a/client/src/Pages/PageSpeed/Monitors/index.tsx
+++ b/client/src/Pages/PageSpeed/Monitors/index.tsx
@@ -1,229 +1,57 @@
-import {
- MonitorBasePageWithStates,
- PageSpeedKeyPriorityFallback,
- ColoredLabel,
-} from "@/Components/design-elements";
-import { Dialog, TextField } from "@/Components/inputs";
-import { HeaderCreate } from "@/Components/common";
+import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
-import type { Monitor } from "@/Types/Monitor";
-import type { Tag } from "@/Types/Tag";
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-import useMediaQuery from "@mui/material/useMediaQuery";
-import { useTheme } from "@mui/material";
-import { SPACING } from "@/Utils/Theme/constants";
-
-import { useTranslation } from "react-i18next";
-import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
-import { useGet, useDelete } from "@/Hooks/UseApi";
-import useDebounce from "@/Hooks/useDebounce";
-import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
+import { useGet } from "@/Hooks/UseApi";
import type { AppSettingsResponse } from "@/Types/Settings";
-import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
+import { MonitorListPage } from "@/Components/monitors";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
const PageSpeedMonitorsPage = () => {
- const { t } = useTranslation();
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
- const { deleteFn, loading: isDeleting } = useDelete();
-
- const settingsUrl = "/settings";
const {
data: settingsData,
isLoading: settingsIsLoading,
error: settingsError,
- } = useGet(settingsUrl);
+ } = useGet("/settings");
- const [selectedMonitor, setSelectedMonitor] = useState(null);
- const isDialogOpen = Boolean(selectedMonitor);
- const [sortField, setSortField] = useState("name");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const [page, setPage] = useState(0);
- const [rowsPerPage, setRowsPerPage] = useState(10);
- const [selectedTags, setSelectedTags] = useState([]);
- const [selectedStatus, setSelectedStatus] = useState("");
- const [selectedState, setSelectedState] = useState("");
- const [search, setSearch] = useState("");
-
- const debouncedSearch = useDebounce(search, 300);
-
- // Status: pass "up"/"down" directly to the API
- // State: "active" -> true, "paused" -> false
- const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "up";
- if (selectedStatus === "down") return "down";
- return undefined;
- }, [selectedStatus]);
-
- const toFilterActive = useMemo(() => {
- if (selectedState === "active") return "true";
- if (selectedState === "paused") return "false";
- return undefined;
- }, [selectedState]);
-
- // Priority: status > isActive > search > sort
- const filterLookup = new Map([
- [toFilterStatus, "status"],
- [toFilterActive, "isActive"],
- ]);
- const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
- const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
- const filter = activeFilter?.[0] || debouncedSearch;
-
- const { data: tags } = useGet("/tags/team");
-
- const monitorsWithChecksUrl = useMemo(() => {
- const params = new URLSearchParams();
- params.append("type", "pagespeed");
- selectedTags.forEach((tagId) => params.append("tags", tagId));
- params.append("limit", "25");
- if (page !== undefined) params.append("page", String(page));
- if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (filter) params.append("filter", filter);
- if (field) params.append("field", field);
- if (sortOrder) params.append("order", sortOrder);
- return `/monitors/team/with-checks?${params.toString()}`;
- }, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
-
- const {
- data: monitorsData,
- isLoading: monitorsIsLoading,
- error: monitorsError,
- refetch,
- } = useGet(
- monitorsWithChecksUrl,
- {},
- { refreshInterval: 30000 }
- );
-
- const monitors = monitorsData?.monitors;
- const monitorsCount = monitorsData?.count ?? 0;
- const summary = monitorsData?.summary ?? null;
-
- const isLoading = monitorsIsLoading || settingsIsLoading;
+ const c = useMonitorListController({
+ types: ["pagespeed"],
+ checksLimit: 25,
+ refreshInterval: 30000,
+ rowsPerPageTable: "pagespeed",
+ rowsPerPageDefault: 10,
+ initialSortField: "name",
+ });
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
- const handleConfirm = async () => {
- if (!selectedMonitor) return;
- await deleteFn(`/monitors/${selectedMonitor.id}`);
- setSelectedMonitor(null);
- refetch();
- };
-
- const handleCancel = () => {
- setSelectedMonitor(null);
- };
-
- const handleClearFilters = useCallback(() => {
- setSelectedStatus("");
- setSelectedState("");
- setSelectedTags([]);
- setSearch("");
- }, []);
-
- const hasActiveFilters = Boolean(
- selectedStatus || selectedState || selectedTags.length > 0 || search
- );
-
- const effectiveTotalCount =
- hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
- ? 1
- : (summary?.totalMonitors ?? 0);
-
return (
- : undefined}
>
-
-
-
-
-
- {
- setSearch(event.target.value);
- }}
- />
-
-
- {selectedTags.length > 0 && (
-
-
- {t("pages.uptime.filters.activeTags")}
-
- {selectedTags.map((tagId) => {
- const tag = tags?.find((t) => t.id === tagId);
- if (!tag) return null;
- return (
-
- );
- })}
-
- )}
-
-
-
+
);
};
diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx
index 70a8e1bb6..c8fccfd3f 100644
--- a/client/src/Pages/Settings/index.tsx
+++ b/client/src/Pages/Settings/index.tsx
@@ -1,4 +1,4 @@
-import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements";
+import { BasePage, ConfigBox, TextLink, Tabs, Tab } from "@/Components/design-elements";
import { Autocomplete, Select, Dialog } from "@/Components/inputs";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
@@ -46,6 +46,80 @@ import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import type { ProxyResponse } from "@/Types/Proxy";
import type { AppSettingsResponse } from "@/Types/Settings";
+import { useSearchParams } from "react-router-dom";
+
+const SETTINGS_TABS = [
+ { key: "general", labelKey: "pages.settings.tabs.general" },
+ { key: "monitoring", labelKey: "pages.settings.tabs.monitoring", adminOnly: true },
+ { key: "email", labelKey: "pages.settings.tabs.email", adminOnly: true },
+ { key: "integrations", labelKey: "pages.settings.tabs.integrations", adminOnly: true },
+ { key: "data", labelKey: "pages.settings.tabs.data", adminOnly: true },
+] as const;
+
+type SettingsTabKey = (typeof SETTINGS_TABS)[number]["key"];
+
+const FIELD_TAB: Record = {
+ checkTTL: "monitoring",
+ globalThresholds: "monitoring",
+ pagespeedApiKey: "integrations",
+ globalProxyEnabled: "integrations",
+ globalProxyId: "integrations",
+ showURL: "general",
+};
+
+const tabForField = (field: string): SettingsTabKey =>
+ field.startsWith("systemEmail")
+ ? "email"
+ : (FIELD_TAB[field.split(".")[0]] ?? "general");
+
+const FIELD_LABEL_KEY: Record = {
+ checkTTL: "pages.settings.form.retention.option.days.label",
+ "globalThresholds.cpu": "pages.settings.form.thresholds.option.cpu.label",
+ "globalThresholds.memory": "pages.settings.form.thresholds.option.memory.label",
+ "globalThresholds.disk": "pages.settings.form.thresholds.option.disk.label",
+ "globalThresholds.temperature":
+ "pages.settings.form.thresholds.option.temperature.label",
+ pagespeedApiKey: "pages.settings.form.pagespeed.option.apiKey.label",
+ globalProxyEnabled: "pages.settings.form.globalProxy.option.enabled.label",
+ showURL: "pages.settings.form.url.option.showURL.label",
+ systemEmailHost: "pages.settings.form.email.option.host.label",
+ systemEmailPort: "pages.settings.form.email.option.port.label",
+ systemEmailAddress: "pages.settings.form.email.option.address.label",
+ systemEmailDisplayName: "pages.settings.form.email.option.displayName.label",
+ systemEmailUser: "pages.settings.form.email.option.user.label",
+ systemEmailPassword: "pages.settings.form.email.option.password.label",
+ systemEmailTLSServername: "pages.settings.form.email.option.tlsServername.label",
+ systemEmailConnectionHost: "pages.settings.form.email.option.connectionHost.label",
+ systemEmailSecure: "pages.settings.form.email.option.secure.label",
+ systemEmailPool: "pages.settings.form.email.option.pool.label",
+ systemEmailIgnoreTLS: "pages.settings.form.email.option.ignoreTLS.label",
+ systemEmailRequireTLS: "pages.settings.form.email.option.requireTLS.label",
+ systemEmailRejectUnauthorized:
+ "pages.settings.form.email.option.rejectUnauthorized.label",
+};
+
+type FieldErrorLeaf = { path: string; message?: string };
+
+const flattenFieldErrors = (errors: unknown, prefix = ""): FieldErrorLeaf[] => {
+ if (typeof errors !== "object" || errors === null) {
+ return [];
+ }
+ return Object.entries(errors).flatMap(([key, value]) => {
+ if (key === "ref") {
+ return [];
+ }
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (typeof value !== "object" || value === null) {
+ return [];
+ }
+ const message = (value as { message?: unknown }).message;
+ if (typeof message === "string") {
+ return [{ path, message }];
+ }
+ const nested = flattenFieldErrors(value, path);
+ return nested.length > 0 ? nested : [{ path }];
+ });
+};
export const SettingsPage = () => {
const theme = useTheme();
@@ -53,6 +127,42 @@ export const SettingsPage = () => {
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
const { toastError } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const visibleTabs = useMemo(
+ () => SETTINGS_TABS.filter((tab) => !("adminOnly" in tab) || isAdmin),
+ [isAdmin]
+ );
+
+ const requestedTab = searchParams.get("tab");
+ const firstVisibleTab: SettingsTabKey = visibleTabs[0]?.key ?? SETTINGS_TABS[0].key;
+ const activeTab: SettingsTabKey =
+ visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? firstVisibleTab;
+
+ const handleTabChange = (tab: SettingsTabKey) => {
+ const updated = new URLSearchParams(searchParams);
+ if (tab === firstVisibleTab) {
+ updated.delete("tab");
+ } else {
+ updated.set("tab", tab);
+ }
+ setSearchParams(updated, { replace: true });
+ };
+
+ // Drop a "tab" param that does not resolve to a rendered tab, or that names
+ // the first tab, which is addressed with no param at all.
+ useEffect(() => {
+ if (requestedTab === null) {
+ return;
+ }
+ if (requestedTab === activeTab && activeTab !== firstVisibleTab) {
+ return;
+ }
+ const updated = new URLSearchParams(searchParams);
+ updated.delete("tab");
+ setSearchParams(updated, { replace: true });
+ }, [requestedTab, activeTab, firstVisibleTab, searchParams, setSearchParams]);
+
// Local state for demo monitors dialog
const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false);
const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost();
@@ -300,6 +410,13 @@ export const SettingsPage = () => {
logger.debug("Form validation errors", errors);
};
+ const revealField = (field: string) => {
+ const target = tabForField(field);
+ if (visibleTabs.some((tab) => tab.key === target)) {
+ handleTabChange(target);
+ }
+ };
+
const languages = Object.keys(i18n.options.resources || {});
const globalProxyEnabled = watch("globalProxyEnabled");
@@ -324,545 +441,594 @@ export const SettingsPage = () => {
component="form"
onSubmit={form.handleSubmit(onSubmit, onError)}
>
-
- option.name}
- isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) =>
- option.id === value.id
- }
- onChange={(_, newValue: TimezoneOption | null) => {
- handleTimezoneChange(newValue);
- }}
- fieldLabel={t("pages.settings.form.timezone.option.timezone.label")}
+ {visibleTabs.length > 1 && (
+ handleTabChange(value)}
+ >
+ {visibleTabs.map((tab) => (
+
- }
- />
-
-
-
- {t("pages.settings.form.ui.option.theme.light")}
-
-
- {t("pages.settings.form.ui.option.theme.dark")}
-
-
-
- {languages.map((lang) => (
-
- {languageNames[lang] ?? lang}
-
- ))}
-
-
-
- {t("pages.settings.form.ui.option.chartType.histogram")}
-
-
- {t("pages.settings.form.ui.option.chartType.heatmap")}
-
-
-
-
- }
- />
- {isAdmin && (
- 0 ? (
-
-
- {globalProxyEnabled && (
-
- )}
-
- ) : (
-
- )
- }
- />
- )}
- {isAdmin && (
-
- {(isApiKeySet === false || apiKeyHasBeenReset === true) && (
-
- )}
+ ))}
+
+ )}
- {isApiKeySet === true && apiKeyHasBeenReset === false && (
-
-
- {t("pages.settings.form.pagespeed.option.apiKey.labelSet")}
-
-
- {t("common.buttons.reset")}
-
-
- )}
- >
- }
- />
- )}
-
- {/* URL Settings */}
-
- }
- />
-
- {/* Clear All Stats */}
- {isAdmin && (
+ {/* general tab */}
+ {activeTab === "general" && (
+
setIsStatsDialogOpen(true)}
- >
- {t("common.buttons.clear")}
-
- }
- />
- )}
-
- {/* Check Retention */}
- {isAdmin && (
-
- value >= CHECK_TTL_SENTINEL
- ? t("pages.settings.form.retention.option.days.unlimited")
- : `${value}`
- }
- formatDisplayValue={(value: number) =>
- value >= CHECK_TTL_SENTINEL
- ? t("pages.settings.form.retention.option.days.unlimited")
- : `${value}`
+ option.name}
+ isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) =>
+ option.id === value.id
}
+ onChange={(_, newValue: TimezoneOption | null) => {
+ handleTimezoneChange(newValue);
+ }}
+ fieldLabel={t("pages.settings.form.timezone.option.timezone.label")}
/>
}
/>
- )}
-
- {/* Global Thresholds */}
- {isAdmin && (
-
-
-
-
-
+
+
+
+ {t("pages.settings.form.ui.option.theme.light")}
+
+
+ {t("pages.settings.form.ui.option.theme.dark")}
+
+
+
+ {languages.map((lang) => (
+
+ {languageNames[lang] ?? lang}
+
+ ))}
+
+
+
+ {t("pages.settings.form.ui.option.chartType.histogram")}
+
+
+ {t("pages.settings.form.ui.option.chartType.heatmap")}
+
+
+
}
/>
- )}
-
-
- {/* Email Settings - Admin Only */}
- {isAdmin && (
-
-
- {(() => {
- const address = form.watch("systemEmailAddress") || "";
- const displayName = form.watch("systemEmailDisplayName")?.trim();
- return (
- <>
-
-
- {JSON.stringify(
- {
- host: form.watch("systemEmailHost") || "",
- port: form.watch("systemEmailPort") || "",
- secure: form.watch("systemEmailSecure") ?? false,
- auth: {
- user: form.watch("systemEmailUser") || address,
- pass: "",
- },
- name:
- form.watch("systemEmailConnectionHost") || "localhost",
- pool: form.watch("systemEmailPool") ?? false,
- tls: {
- rejectUnauthorized:
- form.watch("systemEmailRejectUnauthorized") ?? true,
- ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
- requireTLS: form.watch("systemEmailRequireTLS") ?? false,
- servername: form.watch("systemEmailTLSServername") || "",
- },
- },
- null,
- 2
- )}
-
-
- {address && (
-
-
- {`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
-
-
+ }
+ />
+ {/* About */}
+
+
+ {t("common.appName")} {__APP_VERSION__}
+
+
+ {t("pages.settings.form.about.developedBy")}
+
+
+ https://github.com/bluewave-labs/checkmate
+
+
+ }
+ />
+
+ )}
+
+ {/* monitoring tab */}
+ {activeTab === "monitoring" && (
+
+ {/* Global Thresholds */}
+ {isAdmin && (
+
+
+
+
+
+
- );
- })()}
-
- }
- rightContent={
-
- {/* Email Host */}
-
+ valueLabelDisplay="auto"
+ min={1}
+ max={150}
+ />
+
+ }
+ />
+ )}
+ {/* Check Retention */}
+ {isAdmin && (
+
+ value >= CHECK_TTL_SENTINEL
+ ? t("pages.settings.form.retention.option.days.unlimited")
+ : `${value}`
+ }
+ formatDisplayValue={(value: number) =>
+ value >= CHECK_TTL_SENTINEL
+ ? t("pages.settings.form.retention.option.days.unlimited")
+ : `${value}`
+ }
+ />
+ }
+ />
+ )}
- {/* Email Port */}
-
-
- {/* Email Address */}
-
-
- {/* Email Display Name (Optional) */}
-
-
- {/* Email User (Optional) */}
-
-
- {/* Email Password with Reset Pattern */}
- {isEmailPasswordSet && !emailPasswordHasBeenReset ? (
+ {/* Demo Monitors - Admin Only */}
+ {isAdmin && (
+
-
- {t("pages.settings.form.email.option.password.labelSet")}
-
- {
+ await postDemoMonitors("/monitors/demo", {});
+ }}
+ >
+ {t("common.buttons.addDemo")}
+
+
+ }
+ />
+ )}
+
+ )}
+
+ {/* email tab */}
+ {activeTab === "email" && (
+
+ {/* Email Settings - Admin Only */}
+ {isAdmin && (
+
+
+ {(() => {
+ const address = form.watch("systemEmailAddress") || "";
+ const displayName = form.watch("systemEmailDisplayName")?.trim();
+ return (
+ <>
+
+
+ {JSON.stringify(
+ {
+ host: form.watch("systemEmailHost") || "",
+ port: form.watch("systemEmailPort") || "",
+ secure: form.watch("systemEmailSecure") ?? false,
+ auth: {
+ user: form.watch("systemEmailUser") || address,
+ pass: "",
+ },
+ name:
+ form.watch("systemEmailConnectionHost") ||
+ "localhost",
+ pool: form.watch("systemEmailPool") ?? false,
+ tls: {
+ rejectUnauthorized:
+ form.watch("systemEmailRejectUnauthorized") ?? true,
+ ignoreTLS:
+ form.watch("systemEmailIgnoreTLS") ?? false,
+ requireTLS:
+ form.watch("systemEmailRequireTLS") ?? false,
+ servername:
+ form.watch("systemEmailTLSServername") || "",
+ },
+ },
+ null,
+ 2
+ )}
+
+
+ {address && (
+
+
+ {`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
+
+
+ )}
+ >
+ );
+ })()}
+
+ }
+ rightContent={
+
+ {/* Email Host */}
+
+
+ {/* Email Port */}
+
+
+ {/* Email Address */}
+
+
+ {/* Email Display Name (Optional) */}
+
+
+ {/* Email User (Optional) */}
+
+
+ {/* Email Password with Reset Pattern */}
+ {isEmailPasswordSet && !emailPasswordHasBeenReset ? (
+
+
+ {t("pages.settings.form.email.option.password.labelSet")}
+
+
+
+ {t("common.buttons.reset")}
+
+
+
+ ) : (
+
+ )}
+
+ {/* TLS Servername (Optional) */}
+
+
+ {/* Connection Host (Optional) */}
+
+
+ {/* Boolean Switches */}
+
+ {emailSwitches.map(({ name, label }) => (
+
+ ))}
+
+
+ {/* Test Email Button */}
+
- {t("common.buttons.reset")}
+ {t("common.buttons.sendTestEmail")}
+
+
+ }
+ />
+ )}
+
+ )}
+
+ {/* integrations tab */}
+ {activeTab === "integrations" && (
+
+ {isAdmin && (
+ 0 ? (
+
+
+ {globalProxyEnabled && (
+
+ )}
-
- ) : (
-
- )}
-
- {/* TLS Servername (Optional) */}
-
-
- {/* Connection Host (Optional) */}
-
-
- {/* Boolean Switches */}
-
- {emailSwitches.map(({ name, label }) => (
-
- ))}
-
+ )
+ }
+ />
+ )}
+ {isAdmin && (
+
+ {(isApiKeySet === false || apiKeyHasBeenReset === true) && (
+
+ )}
- {/* Test Email Button */}
-
+ {isApiKeySet === true && apiKeyHasBeenReset === false && (
+
+
+ {t("pages.settings.form.pagespeed.option.apiKey.labelSet")}
+
+
+ {t("common.buttons.reset")}
+
+
+ )}
+ >
+ }
+ />
+ )}
+
+ )}
+
+ {/* data tab */}
+ {activeTab === "data" && (
+
+ {/* Export Monitors - Admin Only */}
+ {isAdmin && (
+
+
+
+ document.getElementById("monitor-import-input")?.click()
+ }
+ disabled={isImportingMonitors}
+ >
+ {t("common.buttons.importFromJSON")}
+
+
+ {t("common.buttons.exportToJSON")}
+
+
+ }
+ />
+ )}
+ {/* Clear All Stats */}
+ {isAdmin && (
+ setIsStatsDialogOpen(true)}
>
- {t("common.buttons.sendTestEmail")}
+ {t("common.buttons.clear")}
-
-
- }
- />
- )}
+ }
+ />
+ )}
- {/* Demo Monitors - Admin Only */}
- {isAdmin && (
-
- {
- await postDemoMonitors("/monitors/demo", {});
- }}
- >
- {t("common.buttons.addDemo")}
-
-
- }
- />
+ {/* Remove All Monitors - Admin Only */}
+ {isAdmin && (
+
+ setIsDemoMonitorsDialogOpen(true)}
+ >
+ {t("common.buttons.removeMonitors")}
+
+
+ }
+ />
+ )}
+
)}
- {/* Remove All Monitors - Admin Only */}
- {isAdmin && (
-
- setIsDemoMonitorsDialogOpen(true)}
- >
- {t("common.buttons.removeMonitors")}
-
-
- }
- />
- )}
-
- {/* Export Monitors - Admin Only */}
- {isAdmin && (
-
-
- document.getElementById("monitor-import-input")?.click()}
- disabled={isImportingMonitors}
- >
- {t("common.buttons.importFromJSON")}
-
-
- {t("common.buttons.exportToJSON")}
-
-
- }
- />
- )}
-
- {/* About */}
-
-
- {t("common.appName")} {__APP_VERSION__}
-
-
- {t("pages.settings.form.about.developedBy")}
-
-
- https://github.com/bluewave-labs/checkmate
-
-
- }
- />
-
{/* Clear Stats Confirmation Dialog */}
{
{
>
{/* Validation Error Display */}
{Object.keys(form.formState.errors).length > 0 && (
-
+
{
component="ul"
sx={{ m: 0, pl: 2 }}
>
- {Object.entries(form.formState.errors).map(([field, error]) => {
- const message =
- typeof error === "object" && error?.message
- ? error.message
- : "Invalid value";
+ {flattenFieldErrors(form.formState.errors).map(({ path, message }) => {
+ const labelKey = FIELD_LABEL_KEY[path];
+ // Labels read "Name - explanation"; keep the name.
+ const label = labelKey ? t(labelKey).split(" - ")[0] : path;
return (
-
+
- {field}: {message}
+ revealField(path)}
+ >
+ {label}
+
+ {message ? `: ${message}` : ""}
);
diff --git a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx
index 407306f1b..f6908c90b 100644
--- a/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx
+++ b/client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx
@@ -25,7 +25,12 @@ export const HeaderConfigStatusControls = ({
}
+ 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/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx
index bcf7177ac..13998aed5 100644
--- a/client/src/Pages/Uptime/Details/index.tsx
+++ b/client/src/Pages/Uptime/Details/index.tsx
@@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
+import type { DateRange } from "@/Types/Query";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -53,7 +54,7 @@ const UptimeDetailsPage = () => {
const [rowsPerPage, setRowsPerPage] = useState(5);
const [geoPage, setGeoPage] = useState(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState(5);
- const [dateRange, setDateRange] = useState("recent");
+ const [dateRange, setDateRange] = useState("recent");
const [selectedLocation, setSelectedLocation] = useState("NA");
const monitorDetailsUrl = useMemo(() => {
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 { t } = useTranslation();
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- const dispatch = useDispatch();
- const isAdmin = useIsAdmin();
- // Redux state
- const rowsPerPage = useSelector(
- (state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
- );
-
- // Local state
- const [selectedTypes, setSelectedTypes] = useState([]);
- const [selectedStatus, setSelectedStatus] = useState("");
- const [selectedState, setSelectedState] = useState("");
- const [selectedTags, setSelectedTags] = useState([]);
- const [page, setPage] = useState(0);
- const [search, setSearch] = useState("");
- const [sortField, setSortField] = useState("");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const [selectedMonitor, setSelectedMonitor] = useState(null);
-
- const isDialogOpen = Boolean(selectedMonitor);
-
- const debouncedSearch = useDebounce(search, 300);
-
- // Convert filter selections to API filter values
- // Status: pass "up"/"down" directly to the API
- // State: "active" -> true, "paused" -> false
- const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "up";
- if (selectedStatus === "down") return "down";
- return undefined;
- }, [selectedStatus]);
-
- const toFilterActive = useMemo(() => {
- if (selectedState === "active") return "true";
- if (selectedState === "paused") return "false";
- return undefined;
- }, [selectedState]);
-
- // Determine field and filter for the API request
- // Priority: status > isActive > search > sort
- const filterLookup = new Map([
- [toFilterStatus, "status"],
- [toFilterActive, "isActive"],
- ]);
- const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
- const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
- const filter = activeFilter?.[0] || debouncedSearch;
-
- // Default to all types when none selected
- const effectiveTypes =
- selectedTypes.length > 0
- ? selectedTypes
- : ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
-
- // Build URL for monitors with checks
- const monitorsWithChecksUrl = useMemo(() => {
- const params = new URLSearchParams();
- effectiveTypes.forEach((type) => params.append("type", type));
- selectedTags.forEach((tagId) => params.append("tags", tagId));
- params.append("limit", "25");
- if (page !== undefined) params.append("page", String(page));
- if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (filter) params.append("filter", filter);
- if (field) params.append("field", field);
- if (sortOrder) params.append("order", sortOrder);
- return `/monitors/team/with-checks?${params.toString()}`;
- }, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
-
- const {
- data: monitorsWithChecksData,
- isLoading: monitorsWithChecksLoading,
- error: monitorsWithChecksError,
- refetch,
- } = useGet(
- monitorsWithChecksUrl,
- {},
- { refreshInterval: 5000, keepPreviousData: true }
- );
-
- const { data: tags } = useGet("/tags/team");
-
- const {
- monitors: monitorsWithChecks,
- summary,
- count,
- } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
-
- // Bulk actions
- const {
- selectedRows,
- setSelectedRows,
- handleBulkPause,
- handleBulkResume,
- handleCancelSelection,
- } = useBulkMonitorActions(refetch, page);
-
- // Delete hook
- const { deleteFn, loading: isDeleting } = useDelete();
-
- // Handlers
- const handleClearFilters = useCallback(() => {
- setSelectedTypes([]);
- setSelectedStatus("");
- setSelectedState("");
- setSelectedTags([]);
- setSearch("");
- }, []);
-
- const handleConfirm = async () => {
- if (!selectedMonitor) return;
- await deleteFn(`/monitors/${selectedMonitor.id}`);
- setSelectedMonitor(null);
- refetch();
- };
-
- const handleCancel = () => {
- setSelectedMonitor(null);
- };
-
- const isLoading = monitorsWithChecksLoading;
-
- // Check if any filters are active
- const hasActiveFilters = Boolean(
- selectedTypes.length > 0 ||
- selectedStatus ||
- selectedState ||
- selectedTags.length > 0 ||
- search
- );
-
- // Show empty state only when there are truly no monitors (not just filtered out)
- // If filters are active and count is 0, pass 1 to prevent empty state fallback
- const effectiveTotalCount =
- hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
- ? 1
- : (summary?.totalMonitors ?? 0);
+ const monitorListController = useMonitorListController({
+ types: "selectable",
+ checksLimit: 25,
+ refreshInterval: 5000,
+ rowsPerPageTable: "monitors",
+ rowsPerPageDefault: 10,
+ });
return (
-
-
-
-
-
-
-
- {
- setSearch(event.target.value);
- }}
- />
-
-
- {selectedTags.length > 0 && (
-
-
- {t("pages.uptime.filters.activeTags")}
-
- {selectedTags.map((tagId) => {
- const tag = tags?.find((t) => t.id === tagId);
- if (!tag) return null;
- return (
-
- );
- })}
-
- )}
-
- {!isLoading && (
-
- }
- onClick={handleBulkResume}
- >
- {t("common.buttons.resume")}
-
- }
- onClick={handleBulkPause}
- >
- {t("common.buttons.pause")}
-
-
- )}
-
{
- dispatch(
- setRowsPerPage({
- value: rowsPerPage,
- table: "monitors",
- })
- );
- setPage(0);
- }}
- selectedRows={selectedRows}
- onSelectionChange={setSelectedRows}
+ monitors={monitorListController.monitors || []}
+ tags={monitorListController.tags || []}
+ refetch={monitorListController.refetch}
+ setSelectedMonitor={monitorListController.setSelectedMonitor}
+ count={monitorListController.count || 0}
+ page={monitorListController.page}
+ setPage={monitorListController.setPage}
+ rowsPerPage={monitorListController.rowsPerPage}
+ sortField={monitorListController.sortField}
+ setSortField={monitorListController.setSortField}
+ sortOrder={monitorListController.sortOrder}
+ setSortOrder={monitorListController.setSortOrder}
+ setRowsPerPage={monitorListController.handleSetRowsPerPage}
+ selectedRows={monitorListController.selectedRows}
+ onSelectionChange={monitorListController.setSelectedRows}
/>
-
-
+
);
};
diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx
index 968b468c2..37e49d1ee 100644
--- a/client/src/Routes/index.tsx
+++ b/client/src/Routes/index.tsx
@@ -20,6 +20,11 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/";
import Infrastructure from "@/Pages/Infrastructure/Monitors";
import InfrastructureDetails from "@/Pages/Infrastructure/Details";
+// Docker
+import Docker from "@/Pages/Docker/Monitors";
+import DockerHostDetails from "@/Pages/Docker/HostDetails";
+import DockerDetails from "@/Pages/Docker/ContainerDetails";
+
// Checks
import Checks from "@/Pages/Checks";
@@ -139,6 +144,26 @@ const Routes = () => {
path="infrastructure/:monitorId"
element={ }
/>
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
}
diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts
index 78e970f04..1585b7771 100644
--- a/client/src/Types/Check.ts
+++ b/client/src/Types/Check.ts
@@ -119,6 +119,90 @@ export interface CheckTimings {
};
}
+// Mirrors DockerContainerSummary in server/src/types/network.ts.
+export interface DockerContainerSummary {
+ total: number;
+ running: number;
+ stopped: number;
+ unhealthy: number;
+}
+
+// Mirrors DockerContainerStates in server/src/types/network.ts.
+export const DockerContainerStates = [
+ "created",
+ "running",
+ "paused",
+ "restarting",
+ "removing",
+ "exited",
+ "dead",
+] as const;
+export type DockerContainerState = (typeof DockerContainerStates)[number];
+
+export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
+export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
+
+export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
+export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
+
+export interface DockerContainerPort {
+ privatePort: number;
+ protocol: DockerPortProtocol;
+ publicPort?: number;
+ hostIp?: string;
+}
+
+export interface DockerContainerMount {
+ type: string;
+ name?: string;
+ source: string;
+ destination: string;
+ mode: string;
+ rw: boolean;
+}
+
+export const DockerLogStreams = ["stdout", "stderr"] as const;
+export type DockerLogStream = (typeof DockerLogStreams)[number];
+
+export interface DockerLogLine {
+ ts: string;
+ stream: DockerLogStream;
+ text: string;
+}
+
+export interface DockerLog {
+ id: string;
+ metadata: {
+ monitorId: string;
+ teamId: string;
+ containerId: string;
+ containerName: string;
+ };
+ lines: DockerLogLine[];
+ gap: boolean;
+ checkedAt: string;
+ expiry: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DockerContainerInfo {
+ id: string;
+ name: string;
+ image: string;
+ state: DockerContainerState;
+ status: string;
+ health: DockerHealthStatus;
+ cpuPct?: number;
+ memoryUsedBytes?: number;
+ memoryLimitBytes?: number;
+ memoryPct?: number;
+ restartCount?: number;
+ startedAt?: string;
+ ports?: DockerContainerPort[];
+ mounts?: DockerContainerMount[];
+}
+
export interface Check {
id: string;
metadata: CheckMetadata;
@@ -133,6 +217,7 @@ export interface Check {
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
+ containerSummary?: DockerContainerSummary;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
@@ -287,6 +372,7 @@ export type CheckSnapshot = Pick<
memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo;
+ containerSummary?: DockerContainerSummary;
};
export interface HasResponseTime {
responseTime: number;
diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts
index a5ed14c01..858c827d5 100644
--- a/client/src/Types/Monitor.ts
+++ b/client/src/Types/Monitor.ts
@@ -3,6 +3,9 @@ import type {
CheckSnapshot,
GroupedUptimeCheck,
DailyCheckBucket,
+ DockerContainerInfo,
+ DockerContainerSummary,
+ DockerLog,
} from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
@@ -30,7 +33,6 @@ export type MonitorType = (typeof MonitorTypes)[number];
export const SelectableMonitorTypes = [
"http",
"ping",
- "docker",
"port",
"game",
"grpc",
@@ -157,6 +159,10 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
+ dockerLogsEnabled?: boolean;
+ dockerTlsCa?: string;
+ dockerTlsCert?: string;
+ dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -269,6 +275,62 @@ export interface HardwareDetailsResponse {
monitorStats: MonitorStats | null;
}
+export interface DockerStatsBucket {
+ _id: string;
+ avgResponseTime: number | null;
+ upCount: number;
+ totalCount: number;
+ avgRunning: number | null;
+ avgTotal: number | null;
+ avgUnhealthy: number | null;
+}
+
+export interface DockerStats {
+ aggregateData: {
+ totalChecks: number;
+ };
+ upChecks: {
+ totalChecks: number;
+ };
+ aggregate: DockerStatsBucket[];
+ latest: {
+ containers: DockerContainerInfo[];
+ summary?: DockerContainerSummary;
+ checkedAt: string;
+ } | null;
+}
+
+export interface DockerDetailsResponse {
+ monitor: Monitor;
+ stats: DockerStats;
+ monitorStats: MonitorStats | null;
+}
+export interface DockerContainerStatsBucket {
+ _id: string;
+ avgCpuPct: number | null;
+ avgMemoryUsedBytes: number | null;
+ avgMemoryPct: number | null;
+ minRestartCount: number | null;
+ maxRestartCount: number | null;
+}
+
+export interface DockerContainerStats {
+ aggregate: DockerContainerStatsBucket[];
+ restartsInRange: number;
+ latest: {
+ container: DockerContainerInfo;
+ checkedAt: string;
+ } | null;
+}
+export interface DockerContainerResponse {
+ monitor: Monitor;
+ stats: DockerContainerStats;
+}
+
+export interface DockerContainerLogsResponse {
+ logs: DockerLog[];
+ nextCursor: string | null;
+}
export interface Game {
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/Types/Query.ts b/client/src/Types/Query.ts
new file mode 100644
index 000000000..6c0b819b9
--- /dev/null
+++ b/client/src/Types/Query.ts
@@ -0,0 +1,2 @@
+export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
+export type DateRange = (typeof DateRanges)[number];
diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts
index 401771f94..c8bbca168 100644
--- a/client/src/Utils/MonitorUtils.ts
+++ b/client/src/Utils/MonitorUtils.ts
@@ -1,6 +1,14 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
+import {
+ DockerLogStreams,
+ type DockerContainerMount,
+ type DockerContainerPort,
+ type DockerContainerState,
+ type DockerLog,
+ type DockerLogLine,
+} from "@/Types/Check";
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record = {
@@ -12,7 +20,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
- docker: "uptime",
+ docker: "docker",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
@@ -32,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",
@@ -41,6 +57,19 @@ export const getValuePalette = (value: ValueType): PaletteKey => {
return paletteMap[value];
};
+export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => {
+ const paletteMap: Record = {
+ paused: "warning",
+ created: "warning",
+ running: "success",
+ restarting: "warning",
+ removing: "warning",
+ exited: "error",
+ dead: "error",
+ };
+ return paletteMap[state];
+};
+
export const getStatusColor = (status: MonitorStatus, theme: any): string => {
if (status === "up") {
return theme.palette.success.light;
@@ -97,3 +126,64 @@ export const formatUrl = (url: string, maxLength: number = 55) => {
? `${strippedUrl.slice(0, maxLength)}…`
: strippedUrl;
};
+
+export const dedupeDockerPorts = (
+ ports: DockerContainerPort[]
+): DockerContainerPort[] => {
+ return ports.filter((port) => {
+ // Discard ipv6 wildcards
+ if (port.hostIp !== "::") return port;
+
+ const isDuplicate = ports.some((otherPort) => {
+ if (otherPort.hostIp !== "0.0.0.0") return false;
+
+ return (
+ otherPort.privatePort === port.privatePort &&
+ otherPort.protocol === port.protocol &&
+ otherPort.publicPort === port.publicPort
+ );
+ });
+ return !isDuplicate;
+ });
+};
+
+const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name);
+
+export const getDockerMountLabel = (mount: DockerContainerMount) => {
+ if (mount.type === "volume" && mount.name)
+ return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name;
+ if (mount.source) return mount.source;
+ return mount.type;
+};
+
+export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const;
+export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number];
+
+export interface DockerLogRow extends DockerLogLine {
+ key: string;
+ gapBefore: boolean; // This signifies lines skipped
+}
+
+export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => {
+ const rows: DockerLogRow[] = [];
+ for (const log of [...logs].reverse()) {
+ log.lines.forEach((line: DockerLogLine, idx: number) => {
+ rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap });
+ });
+ }
+ return rows;
+};
+
+export const filterDockerLogRows = (
+ rows: DockerLogRow[],
+ stream: DockerLogStreamFilter,
+ query: string
+): DockerLogRow[] => {
+ const normalizedQuery = query.trim().toLowerCase();
+ return rows.filter((row) => {
+ return (
+ (stream === "all" || row.stream === stream) &&
+ (normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery))
+ );
+ });
+};
diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts
index 5257ab92b..ae3a7d89d 100644
--- a/client/src/Utils/Theme/Palette.ts
+++ b/client/src/Utils/Theme/Palette.ts
@@ -1,4 +1,5 @@
import { alpha } from "@mui/material/styles";
+import { HOVER } from "@/Utils/Theme/constants";
const typographyBase = 13;
@@ -17,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",
@@ -39,9 +54,34 @@ export const lightPalette = {
primary: {
main: colors.brandGreen,
},
+ action: {
+ rowHover: alpha(colors.gray900, HOVER.ROW),
+ controlHover: alpha(colors.gray900, HOVER.CONTROL),
+ selected: alpha(colors.brandGreen, 0.06),
+ selectedHover: alpha(colors.brandGreen, 0.1),
+ },
secondary: {
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,
},
@@ -64,6 +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,
@@ -72,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 b589be3a3..15b0b7c05 100644
--- a/client/src/Utils/Theme/Theme.ts
+++ b/client/src/Utils/Theme/Theme.ts
@@ -13,6 +13,11 @@ declare module "@mui/material/styles" {
eyebrow?: React.CSSProperties;
fontFamilyMonospace?: string;
}
+ interface TypeAction {
+ rowHover: string;
+ controlHover: string;
+ selectedHover: string;
+ }
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
@@ -53,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 =
@@ -81,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,
@@ -127,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,
},
}),
@@ -181,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 518657183..950beff49 100644
--- a/client/src/Utils/Theme/constants.ts
+++ b/client/src/Utils/Theme/constants.ts
@@ -18,6 +18,10 @@ 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
+ CONTROL: 0.05, // Stronger overlay for controls nested inside a hoverable row
} as const;
diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts
index 00ab3aaff..8a2b6e726 100644
--- a/client/src/Validation/monitor.ts
+++ b/client/src/Validation/monitor.ts
@@ -108,10 +108,31 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
-// Docker monitor schema
+// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
+// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts.
+const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
+const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
+
+export const isDockerTlsUrl = (url: string | undefined): boolean =>
+ dockerTlsUrlRegex.test(url?.trim() ?? "");
+
const dockerSchema = baseSchema.extend({
type: z.literal("docker"),
- url: z.string().min(1, "Container ID is required"),
+ url: z
+ .string()
+ .min(1, "Docker host URL is required")
+ .refine(
+ (url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url),
+ "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
+ ),
+ ignoreTlsErrors: z.boolean(),
+ dockerTlsCa: z.string(),
+ dockerTlsCert: z.string(),
+ // blank on edit keeps the stored key.
+ dockerTlsKey: z.string(),
+ // Read only mirror of the server flag, stripped on submit
+ dockerTlsKeySet: z.boolean(),
+ dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
});
// Game server monitor schema
@@ -216,7 +237,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema,
]);
-// Mirrors the server's refineProxySelection (monitorValidation.ts)
+// Mirrors the server's refineProxySelection and refineDockerTls
+// (monitorValidation.ts). PEM contents are only parsed on the server.
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
@@ -225,6 +247,30 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom",
});
}
+
+ if (data.type === "docker" && isDockerTlsUrl(data.url)) {
+ if (!data.dockerTlsCert.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["dockerTlsCert"],
+ message: "Client certificate is required for a TLS Docker host",
+ });
+ }
+ if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["dockerTlsKey"],
+ message: "Client key is required for a TLS Docker host",
+ });
+ }
+ if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["dockerTlsCa"],
+ message: "CA certificate is required unless TLS errors are ignored",
+ });
+ }
+ }
});
export type MonitorFormData = z.infer;
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
+
+ Design by Annie Hsu
+
+
+
\ 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/ar.json b/client/src/locales/ar.json
index 55f34650d..82015e849 100644
--- a/client/src/locales/ar.json
+++ b/client/src/locales/ar.json
@@ -181,7 +181,13 @@
"statusPages": "صفحات الحالة",
"maintenance": "الصيانة",
"logs": "السجلات",
- "settings": "الإعدادات"
+ "settings": "الإعدادات",
+ "groups": {
+ "monitoring": "المراقبة",
+ "alerting": "التنبيهات",
+ "reporting": "التقارير",
+ "configuration": "الإعدادات العامة"
+ }
},
"bottomMenu": {
"support": "الدعم",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "عام",
+ "monitoring": "المراقبة",
+ "email": "البريد الإلكتروني",
+ "integrations": "التكاملات",
+ "data": "البيانات"
+ },
"form": {
"timezone": {
"title": "المنطقة الزمنية للعرض",
diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json
index 7e39eca86..912c5b198 100644
--- a/client/src/locales/ca.json
+++ b/client/src/locales/ca.json
@@ -189,7 +189,13 @@
"statusPages": "Pàgines d'estat",
"maintenance": "Manteniment",
"logs": "Registres",
- "settings": "Configuració"
+ "settings": "Configuració",
+ "groups": {
+ "monitoring": "Monitoratge",
+ "alerting": "Alertes",
+ "reporting": "Informes",
+ "configuration": "Configuració"
+ }
},
"bottomMenu": {
"support": "Suport",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "General",
+ "monitoring": "Monitoratge",
+ "email": "Correu electrònic",
+ "integrations": "Integracions",
+ "data": "Dades"
+ },
"form": {
"timezone": {
"title": "Zona horària de visualització",
diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json
index d04b9b23b..1d98d565a 100644
--- a/client/src/locales/cs.json
+++ b/client/src/locales/cs.json
@@ -181,7 +181,13 @@
"statusPages": "Stavové stránky",
"maintenance": "Údržba",
"logs": "Záznamy",
- "settings": "Nastavení"
+ "settings": "Nastavení",
+ "groups": {
+ "monitoring": "Monitorování",
+ "alerting": "Upozornění",
+ "reporting": "Přehledy",
+ "configuration": "Konfigurace"
+ }
},
"bottomMenu": {
"support": "Podpora",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Obecné",
+ "monitoring": "Monitorování",
+ "email": "E-mail",
+ "integrations": "Integrace",
+ "data": "Data"
+ },
"form": {
"timezone": {
"title": "Zobrazované časové pásmo",
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index d43d5d52a..6a39ee1aa 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -181,7 +181,13 @@
"statusPages": "Statusseite",
"maintenance": "Wartung",
"logs": "Protokolle",
- "settings": "Einstellungen"
+ "settings": "Einstellungen",
+ "groups": {
+ "monitoring": "Überwachung",
+ "alerting": "Benachrichtigungen",
+ "reporting": "Berichte",
+ "configuration": "Konfiguration"
+ }
},
"bottomMenu": {
"support": "Support",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Allgemein",
+ "monitoring": "Überwachung",
+ "email": "E-Mail",
+ "integrations": "Integrationen",
+ "data": "Daten"
+ },
"form": {
"timezone": {
"title": "Anzeigesprache Zeitzone",
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index e82f84949..bb17d9e29 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -66,9 +66,11 @@
"charts": {
"labels": {
"averageResponseTime": "Average response time",
+ "cpuUsage": "CPU usage",
"downtime": "Downtime",
"high": "High",
"low": "Low",
+ "memoryUsage": "Memory usage",
"responseTime": "Response time",
"timingPhases": "Timing phases",
"totalResponseTime": "Total response time",
@@ -78,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",
@@ -102,7 +106,11 @@
"idle": "Idle",
"na": "N/A",
"resolved": "Resolved",
- "responseTime": "Response time"
+ "responseTime": "Response time",
+ "state": "State",
+ "uptime": "Uptime",
+ "health": "Health",
+ "restarts": "Restarts"
},
"form": {
"autocomplete": {
@@ -203,6 +211,7 @@
"uptime": "Uptime",
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
+ "docker": "Docker",
"notifications": "Notifications",
"tags": "Tags",
"proxies": "Proxies",
@@ -211,7 +220,13 @@
"statusPages": "Status pages",
"maintenance": "Maintenance",
"logs": "Logs",
- "settings": "Settings"
+ "settings": "Settings",
+ "groups": {
+ "monitoring": "Monitoring",
+ "alerting": "Alerting",
+ "reporting": "Reporting",
+ "configuration": "Configuration"
+ }
},
"bottomMenu": {
"support": "Support",
@@ -619,9 +634,9 @@
},
"general": {
"option": {
- "container": {
- "label": "Container name/ID",
- "placeholder": "my-app or abcd1234"
+ "dockerHost": {
+ "label": "Docker host",
+ "placeholder": "unix:///var/run/docker.sock"
},
"host": {
"label": "Host",
@@ -703,7 +718,7 @@
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
- "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
+ "docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. Every container on the host is monitored.",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the 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.",
@@ -743,7 +758,7 @@
},
"type": {
"description": "Select the type of check to perform",
- "optionDockerDescription": "Use Docker to monitor if a container is running.",
+ "optionDockerDescription": "Monitor a Docker host and every container running on it.",
"optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
@@ -801,6 +816,15 @@
}
}
},
+ "dockerLogs": {
+ "title": "Container logs",
+ "description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.",
+ "option": {
+ "enabled": {
+ "label": "Collect container logs"
+ }
+ }
+ },
"stats": {
"title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
@@ -810,6 +834,29 @@
"description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.",
"confirm": "Yes, clear all stats"
}
+ },
+ "dockerTls": {
+ "title": "TLS credentials",
+ "description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.",
+ "option": {
+ "ca": {
+ "label": "CA certificate",
+ "placeholder": "-----BEGIN CERTIFICATE-----"
+ },
+ "cert": {
+ "label": "Client certificate",
+ "placeholder": "-----BEGIN CERTIFICATE-----"
+ },
+ "key": {
+ "label": "Client key",
+ "placeholder": "-----BEGIN PRIVATE KEY-----",
+ "stored": "A key is stored. Leave blank to keep it."
+ },
+ "ignoreTls": {
+ "label": "Ignore TLS/SSL errors",
+ "description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them."
+ }
+ }
}
}
},
@@ -918,15 +965,81 @@
"description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history."
}
},
+ "docker": {
+ "header": {
+ "title": "Docker monitors",
+ "description": "Monitor your Docker hosts and every container running on them."
+ },
+ "fallback": {
+ "title": "No Docker monitors yet",
+ "description": "Add a Docker host to monitor its containers.",
+ "actionButton": "Create a monitor"
+ },
+ "table": {
+ "headers": {
+ "host": "Host",
+ "containers": "Containers"
+ },
+ "containersRunning": "{{running}} of {{total}} running"
+ },
+ "container": {
+ "header": {
+ "title": "Container details",
+ "description": "State, resource usage, and logs for this container."
+ },
+ "tabs": {
+ "overview": "Overview",
+ "logs": "Logs"
+ },
+ "logs": {
+ "recentLines": "{{count}} recent lines",
+ "loading": "Loading...",
+ "updatedSecondsAgo": "Updated {{count}}s ago",
+ "updatedMinutesAgo": "Updated {{count}}m ago",
+ "skipped": "Some log entries were skipped",
+ "loadOlder": "Load older logs",
+ "loadingOlder": "Loading...",
+ "noOlder": "Beginning of retained logs",
+ "loadError": "Could not load logs",
+ "loadOlderError": "Could not load older logs",
+ "empty": {
+ "title": "No logs collected yet",
+ "description": "Lines will appear here after the next check. Only output written since collection was turned on is stored."
+ },
+ "disabled": {
+ "title": "Log collection is off for this host",
+ "description": "Turn on container logs in the monitor settings to start collecting.",
+ "action": "Configure monitor"
+ },
+ "forbidden": {
+ "title": "You don't have access to container logs",
+ "description": "Only administrators can view logs. Ask an administrator for access if you need to see them."
+ }
+ }
+ },
+ "host": {
+ "statBoxes": {
+ "containers": "Containers",
+ "running": "Running",
+ "stopped": "Stopped",
+ "unhealthy": "Unhealthy"
+ },
+ "table": {
+ "headers": {
+ "container": "Containers",
+ "cpu": "CPU",
+ "memory": "Memory"
+ }
+ }
+ }
+ },
"infrastructure": {
"charts": {
"labels": {
- "cpu": "CPU usage",
"disk": "Disk usage",
- "memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent",
- "loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.",
+ "loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.",
"temp": "CPU temperature"
}
},
@@ -1163,7 +1276,7 @@
},
"startTime": {
"title": "Start time",
- "description": "Set the start time and duration for your maintenance window. All values are in UTC",
+ "description": "Set the start time and duration for your maintenance window. All values are in your local time zone.",
"option": {
"duration": {
"label": "Duration"
@@ -1245,6 +1358,7 @@
"teams": "Teams",
"telegram": "Telegram",
"pushover": "Pushover",
+ "signalgrid": "Signalgrid",
"twilio": "Twilio",
"ntfy": "ntfy"
}
@@ -1265,6 +1379,14 @@
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
+ "signalgrid": {
+ "title": "Signalgrid configuration",
+ "description": "Configure Signalgrid to receive push notifications on your devices.",
+ "optionClientKey": "Client key",
+ "placeholderClientKey": "Your Signalgrid client key",
+ "optionChannel": "Channel",
+ "placeholderChannel": "Your Signalgrid channel"
+ },
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
@@ -1333,6 +1455,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "General",
+ "monitoring": "Monitoring",
+ "email": "Email",
+ "integrations": "Integrations",
+ "data": "Data"
+ },
"form": {
"timezone": {
"title": "Display timezone",
@@ -1390,7 +1519,7 @@
}
},
"url": {
- "title": "Monitor IP/URL on Status Page",
+ "title": "Monitor IP/URL on status page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
"option": {
"showURL": {
@@ -1422,7 +1551,7 @@
}
},
"thresholds": {
- "title": "Global Thresholds",
+ "title": "Global thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
"option": {
"cpu": {
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index 589399a83..ac7e47c83 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -181,7 +181,13 @@
"statusPages": "Páginas de estado",
"maintenance": "Mantenimiento",
"logs": "Registros",
- "settings": "Configuración"
+ "settings": "Configuración",
+ "groups": {
+ "monitoring": "Monitorización",
+ "alerting": "Alertas",
+ "reporting": "Informes",
+ "configuration": "Configuración"
+ }
},
"bottomMenu": {
"support": "Soporte",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "General",
+ "monitoring": "Monitorización",
+ "email": "Correo electrónico",
+ "integrations": "Integraciones",
+ "data": "Datos"
+ },
"form": {
"timezone": {
"title": "Zona horaria de visualización",
diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json
index 030698d44..53bb68fbc 100644
--- a/client/src/locales/fi.json
+++ b/client/src/locales/fi.json
@@ -181,7 +181,13 @@
"statusPages": "Tilasivut",
"maintenance": "Huolto",
"logs": "Lokit",
- "settings": "Asetukset"
+ "settings": "Asetukset",
+ "groups": {
+ "monitoring": "Valvonta",
+ "alerting": "Hälytykset",
+ "reporting": "Raportointi",
+ "configuration": "Määritykset"
+ }
},
"bottomMenu": {
"support": "Tuki",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Yleiset",
+ "monitoring": "Valvonta",
+ "email": "Sähköposti",
+ "integrations": "Integraatiot",
+ "data": "Tiedot"
+ },
"form": {
"timezone": {
"title": "Näytettävä aikavyöhyke",
diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json
index 1e0a6b096..da3f965be 100644
--- a/client/src/locales/fr.json
+++ b/client/src/locales/fr.json
@@ -181,7 +181,13 @@
"statusPages": "Pages de statut",
"maintenance": "Maintenance",
"logs": "Journaux",
- "settings": "Paramètres"
+ "settings": "Paramètres",
+ "groups": {
+ "monitoring": "Surveillance",
+ "alerting": "Alertes",
+ "reporting": "Rapports",
+ "configuration": "Configuration"
+ }
},
"bottomMenu": {
"support": "Support",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Général",
+ "monitoring": "Surveillance",
+ "email": "E-mail",
+ "integrations": "Intégrations",
+ "data": "Données"
+ },
"form": {
"timezone": {
"title": "Fuseau horaire d'affichage",
diff --git a/client/src/locales/it.json b/client/src/locales/it.json
index 3d6d1ea16..4f4714675 100644
--- a/client/src/locales/it.json
+++ b/client/src/locales/it.json
@@ -193,7 +193,13 @@
"statusPages": "Pagine di stato",
"maintenance": "Manutenzione",
"logs": "Log",
- "settings": "Impostazioni"
+ "settings": "Impostazioni",
+ "groups": {
+ "monitoring": "Monitoraggio",
+ "alerting": "Avvisi",
+ "reporting": "Report",
+ "configuration": "Configurazione"
+ }
},
"bottomMenu": {
"support": "Supporto",
@@ -1237,6 +1243,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Generale",
+ "monitoring": "Monitoraggio",
+ "email": "Email",
+ "integrations": "Integrazioni",
+ "data": "Dati"
+ },
"form": {
"timezone": {
"title": "Fuso orario di visualizzazione",
diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json
index 16e2ec641..cf05c64b0 100644
--- a/client/src/locales/ja.json
+++ b/client/src/locales/ja.json
@@ -181,7 +181,13 @@
"statusPages": "ステータスページ",
"maintenance": "メンテナンス",
"logs": "ログ",
- "settings": "設定"
+ "settings": "設定",
+ "groups": {
+ "monitoring": "監視",
+ "alerting": "アラート",
+ "reporting": "レポート",
+ "configuration": "設定"
+ }
},
"bottomMenu": {
"support": "サポート",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "一般",
+ "monitoring": "監視",
+ "email": "メール",
+ "integrations": "連携",
+ "data": "データ"
+ },
"form": {
"timezone": {
"title": "表示タイムゾーン",
diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json
index 81509ee6f..5afb59748 100644
--- a/client/src/locales/pl.json
+++ b/client/src/locales/pl.json
@@ -214,7 +214,13 @@
"settings": "Ustawienia",
"statusPages": "Strony statusu",
"tags": "Tagi",
- "uptime": "Czas działania"
+ "uptime": "Czas działania",
+ "groups": {
+ "monitoring": "Monitorowanie",
+ "alerting": "Alerty",
+ "reporting": "Raporty",
+ "configuration": "Konfiguracja"
+ }
},
"starPrompt": {
"description": "Zobacz najnowsze wydania i pomóż rozwijać społeczność na GitHub",
@@ -1207,6 +1213,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Ogólne",
+ "monitoring": "Monitorowanie",
+ "email": "E-mail",
+ "integrations": "Integracje",
+ "data": "Dane"
+ },
"form": {
"about": {
"developedBy": "Opracowane przez Bluewave Labs",
diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json
index c75ead3ef..225c212a2 100644
--- a/client/src/locales/pt-BR.json
+++ b/client/src/locales/pt-BR.json
@@ -181,7 +181,13 @@
"statusPages": "Pagina de status",
"maintenance": "Manutenção",
"logs": "Registros",
- "settings": "Configurações"
+ "settings": "Configurações",
+ "groups": {
+ "monitoring": "Monitoramento",
+ "alerting": "Alertas",
+ "reporting": "Relatórios",
+ "configuration": "Configuração"
+ }
},
"bottomMenu": {
"support": "Suporte",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Geral",
+ "monitoring": "Monitoramento",
+ "email": "E-mail",
+ "integrations": "Integrações",
+ "data": "Dados"
+ },
"form": {
"timezone": {
"title": "Fuso horário de exibição",
diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json
index cf5e486f7..ff9960ebe 100644
--- a/client/src/locales/ru.json
+++ b/client/src/locales/ru.json
@@ -181,7 +181,13 @@
"statusPages": "Страницы статуса",
"maintenance": "Обслуживание",
"logs": "Логи",
- "settings": "Настройки"
+ "settings": "Настройки",
+ "groups": {
+ "monitoring": "Мониторинг",
+ "alerting": "Оповещения",
+ "reporting": "Отчёты",
+ "configuration": "Конфигурация"
+ }
},
"bottomMenu": {
"support": "Поддержка",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Общие",
+ "monitoring": "Мониторинг",
+ "email": "Электронная почта",
+ "integrations": "Интеграции",
+ "data": "Данные"
+ },
"form": {
"timezone": {
"title": "Часовой пояс отображения",
diff --git a/client/src/locales/th.json b/client/src/locales/th.json
index 66d354adc..e6a0d8389 100644
--- a/client/src/locales/th.json
+++ b/client/src/locales/th.json
@@ -181,7 +181,13 @@
"statusPages": "หน้าสถานะ",
"maintenance": "การบำรุงรักษา",
"logs": "บันทึก",
- "settings": "การตั้งค่า"
+ "settings": "การตั้งค่า",
+ "groups": {
+ "monitoring": "การตรวจสอบ",
+ "alerting": "การแจ้งเตือน",
+ "reporting": "รายงาน",
+ "configuration": "การกำหนดค่า"
+ }
},
"bottomMenu": {
"support": "การสนับสนุน",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "ทั่วไป",
+ "monitoring": "การตรวจสอบ",
+ "email": "อีเมล",
+ "integrations": "การเชื่อมต่อ",
+ "data": "ข้อมูล"
+ },
"form": {
"timezone": {
"title": "เขตเวลาที่แสดง",
diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json
index 26f8aace0..dc0f3444c 100644
--- a/client/src/locales/tr.json
+++ b/client/src/locales/tr.json
@@ -181,7 +181,13 @@
"statusPages": "Durum sayfaları",
"maintenance": "Bakım",
"logs": "Günlükler",
- "settings": "Ayarlar"
+ "settings": "Ayarlar",
+ "groups": {
+ "monitoring": "İzleme",
+ "alerting": "Uyarılar",
+ "reporting": "Raporlama",
+ "configuration": "Yapılandırma"
+ }
},
"bottomMenu": {
"support": "Destek",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Genel",
+ "monitoring": "İzleme",
+ "email": "E-posta",
+ "integrations": "Entegrasyonlar",
+ "data": "Veri"
+ },
"form": {
"timezone": {
"title": "Görüntüleme saat dilimi",
diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json
index d1cb983d1..807a9b2d0 100644
--- a/client/src/locales/uk.json
+++ b/client/src/locales/uk.json
@@ -181,7 +181,13 @@
"statusPages": "Сторінки статусу",
"maintenance": "Обслуговування",
"logs": "Журнали",
- "settings": "Налаштування"
+ "settings": "Налаштування",
+ "groups": {
+ "monitoring": "Моніторинг",
+ "alerting": "Сповіщення",
+ "reporting": "Звіти",
+ "configuration": "Конфігурація"
+ }
},
"bottomMenu": {
"support": "Підтримка",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Загальні",
+ "monitoring": "Моніторинг",
+ "email": "Електронна пошта",
+ "integrations": "Інтеграції",
+ "data": "Дані"
+ },
"form": {
"timezone": {
"title": "Часовий пояс відображення",
diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json
index 81da1c6fc..899c8cb0c 100644
--- a/client/src/locales/vi.json
+++ b/client/src/locales/vi.json
@@ -181,7 +181,13 @@
"statusPages": "Trang trạng thái",
"maintenance": "Bảo trì",
"logs": "Nhật ký",
- "settings": "Cài đặt"
+ "settings": "Cài đặt",
+ "groups": {
+ "monitoring": "Giám sát",
+ "alerting": "Cảnh báo",
+ "reporting": "Báo cáo",
+ "configuration": "Cấu hình"
+ }
},
"bottomMenu": {
"support": "Hỗ trợ",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "Chung",
+ "monitoring": "Giám sát",
+ "email": "Email",
+ "integrations": "Tích hợp",
+ "data": "Dữ liệu"
+ },
"form": {
"timezone": {
"title": "Múi giờ hiển thị",
diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json
index 1a455d41c..959d09f38 100644
--- a/client/src/locales/zh-CN.json
+++ b/client/src/locales/zh-CN.json
@@ -181,7 +181,13 @@
"statusPages": "状态页",
"maintenance": "维护",
"logs": "日志",
- "settings": "设置"
+ "settings": "设置",
+ "groups": {
+ "monitoring": "监控",
+ "alerting": "告警",
+ "reporting": "报表",
+ "configuration": "配置"
+ }
},
"bottomMenu": {
"support": "支持",
@@ -1142,6 +1148,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "常规",
+ "monitoring": "监控",
+ "email": "邮件",
+ "integrations": "集成",
+ "data": "数据"
+ },
"form": {
"timezone": {
"title": "显示时区",
diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json
index a15a2a2bb..16849cc6e 100644
--- a/client/src/locales/zh-TW.json
+++ b/client/src/locales/zh-TW.json
@@ -181,7 +181,13 @@
"statusPages": "狀態頁面",
"maintenance": "維護",
"logs": "日誌",
- "settings": "設定"
+ "settings": "設定",
+ "groups": {
+ "monitoring": "監控",
+ "alerting": "警示",
+ "reporting": "報表",
+ "configuration": "設定"
+ }
},
"bottomMenu": {
"support": "支援",
@@ -1141,6 +1147,13 @@
}
},
"settings": {
+ "tabs": {
+ "general": "一般",
+ "monitoring": "監控",
+ "email": "郵件",
+ "integrations": "整合",
+ "data": "資料"
+ },
"form": {
"timezone": {
"title": "顯示時區",
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/client/src/store.ts b/client/src/store.ts
index 9e064b55f..d2a1b96d8 100644
--- a/client/src/store.ts
+++ b/client/src/store.ts
@@ -2,6 +2,7 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit";
import authReducer from "@/Features/Auth/authSlice";
import uiReducer from "@/Features/UI/uiSlice";
import storage from "redux-persist/lib/storage";
+import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2";
import {
persistReducer,
persistStore,
@@ -24,6 +25,8 @@ const persistConfig = {
storage,
whitelist: ["auth", "ui"],
transforms: [authTransform],
+ // Merge two levels deep on rehydrate so a slice key added in a new release keeps initial value
+ stateReconciler: autoMergeLevel2,
};
const rootReducer = combineReducers({
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=
+
# Feature Flags
# -------------
# Set to "false" to disable the selectable theme feature on status pages.
diff --git a/server/openapi.json b/server/openapi.json
index c2408b580..4b4faf896 100644
--- a/server/openapi.json
+++ b/server/openapi.json
@@ -463,6 +463,18 @@
"geoCheckInterval": {
"type": "number"
},
+ "dockerLogsEnabled": {
+ "type": "boolean"
+ },
+ "dockerTlsCa": {
+ "type": "string"
+ },
+ "dockerTlsCert": {
+ "type": "string"
+ },
+ "dockerTlsKeySet": {
+ "type": "boolean"
+ },
"dnsServer": {
"type": "string"
},
@@ -510,6 +522,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
+ "dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -678,6 +691,18 @@
"geoCheckInterval": {
"type": "number"
},
+ "dockerLogsEnabled": {
+ "type": "boolean"
+ },
+ "dockerTlsCa": {
+ "type": "string"
+ },
+ "dockerTlsCert": {
+ "type": "string"
+ },
+ "dockerTlsKeySet": {
+ "type": "boolean"
+ },
"dnsServer": {
"type": "string"
},
@@ -727,6 +752,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
+ "dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -923,6 +949,9 @@
{
"$ref": "#/components/schemas/PushoverNotification"
},
+ {
+ "$ref": "#/components/schemas/SignalgridNotification"
+ },
{
"$ref": "#/components/schemas/TwilioNotification"
},
@@ -943,6 +972,7 @@
"teams": "#/components/schemas/TeamsNotification",
"telegram": "#/components/schemas/TelegramNotification",
"pushover": "#/components/schemas/PushoverNotification",
+ "signalgrid": "#/components/schemas/SignalgridNotification",
"twilio": "#/components/schemas/TwilioNotification",
"ntfy": "#/components/schemas/NtfyNotification"
}
@@ -1374,6 +1404,34 @@
"accessToken": "a1234567890abcdef"
}
},
+ "SignalgridNotification": {
+ "type": "object",
+ "properties": {
+ "notificationName": {
+ "type": "string",
+ "minLength": 1
+ },
+ "type": {
+ "type": "string",
+ "enum": ["signalgrid"]
+ },
+ "address": {
+ "type": "string",
+ "minLength": 1
+ },
+ "accessToken": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "required": ["notificationName", "type", "address", "accessToken"],
+ "example": {
+ "notificationName": "Signalgrid",
+ "type": "signalgrid",
+ "address": "your-channel",
+ "accessToken": "your-client-key"
+ }
+ },
"TwilioNotification": {
"type": "object",
"properties": {
@@ -1648,6 +1706,24 @@
},
"performance": {
"type": "number"
+ },
+ "containerSummary": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "number"
+ },
+ "running": {
+ "type": "number"
+ },
+ "stopped": {
+ "type": "number"
+ },
+ "unhealthy": {
+ "type": "number"
+ }
+ },
+ "required": ["total", "running", "stopped", "unhealthy"]
}
},
"required": ["id", "status", "responseTime", "statusCode", "message", "createdAt"],
@@ -6936,6 +7012,1275 @@
}
}
},
+ "/monitors/docker/details/{monitorId}": {
+ "get": {
+ "tags": ["monitors"],
+ "summary": "Get Docker host details for a monitor",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "monitorId",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": ["recent", "hour", "day", "week", "month", "all"]
+ },
+ "required": false,
+ "name": "dateRange",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "monitor": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
+ },
+ "url": {
+ "type": "string"
+ },
+ "port": {
+ "type": "number"
+ },
+ "isActive": {
+ "type": "boolean"
+ },
+ "interval": {
+ "type": "number"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"]
+ },
+ "statusWindowSize": {
+ "type": "number"
+ },
+ "statusWindowThreshold": {
+ "type": "number"
+ },
+ "ignoreTlsErrors": {
+ "type": "boolean"
+ },
+ "proxyMode": {
+ "type": "string",
+ "enum": ["inherit", "none", "custom"]
+ },
+ "proxyId": {
+ "type": "string"
+ },
+ "useAdvancedMatching": {
+ "type": "boolean"
+ },
+ "jsonPath": {
+ "type": "string"
+ },
+ "expectedValue": {
+ "type": "string"
+ },
+ "matchMethod": {
+ "type": "string",
+ "enum": ["equal", "include", "regex"]
+ },
+ "method": {
+ "type": "string",
+ "enum": ["GET", "HEAD"]
+ },
+ "notifications": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "customUpCodes": {
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ "secret": {
+ "type": "string"
+ },
+ "cpuAlertThreshold": {
+ "type": "number"
+ },
+ "memoryAlertThreshold": {
+ "type": "number"
+ },
+ "diskAlertThreshold": {
+ "type": "number"
+ },
+ "tempAlertThreshold": {
+ "type": "number"
+ },
+ "selectedDisks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "gameId": {
+ "type": "string"
+ },
+ "grpcServiceName": {
+ "type": "string"
+ },
+ "group": {
+ "type": "string",
+ "nullable": true
+ },
+ "geoCheckEnabled": {
+ "type": "boolean"
+ },
+ "geoCheckLocations": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["EU", "NA", "AS", "SA", "AF", "OC"]
+ }
+ },
+ "geoCheckInterval": {
+ "type": "number"
+ },
+ "dockerLogsEnabled": {
+ "type": "boolean"
+ },
+ "dockerTlsCa": {
+ "type": "string"
+ },
+ "dockerTlsCert": {
+ "type": "string"
+ },
+ "dockerTlsKeySet": {
+ "type": "boolean"
+ },
+ "dnsServer": {
+ "type": "string"
+ },
+ "dnsRecordType": {
+ "type": "string",
+ "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
+ },
+ "teamId": {
+ "type": "string"
+ },
+ "userId": {
+ "type": "string"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "lastEvaluatedAt": {
+ "type": "number"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "type",
+ "url",
+ "isActive",
+ "interval",
+ "status",
+ "statusWindowSize",
+ "statusWindowThreshold",
+ "ignoreTlsErrors",
+ "proxyMode",
+ "useAdvancedMatching",
+ "method",
+ "notifications",
+ "tags",
+ "cpuAlertThreshold",
+ "memoryAlertThreshold",
+ "diskAlertThreshold",
+ "tempAlertThreshold",
+ "selectedDisks",
+ "geoCheckEnabled",
+ "geoCheckLocations",
+ "geoCheckInterval",
+ "dockerLogsEnabled",
+ "teamId",
+ "userId",
+ "createdAt",
+ "updatedAt",
+ "lastEvaluatedAt",
+ "id"
+ ],
+ "additionalProperties": {
+ "nullable": true
+ }
+ },
+ "stats": {
+ "type": "object",
+ "properties": {
+ "aggregateData": {
+ "type": "object",
+ "properties": {
+ "totalChecks": {
+ "type": "number"
+ }
+ },
+ "required": ["totalChecks"]
+ },
+ "upChecks": {
+ "type": "object",
+ "properties": {
+ "totalChecks": {
+ "type": "number"
+ }
+ },
+ "required": ["totalChecks"]
+ },
+ "aggregate": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "_id": {
+ "type": "string"
+ },
+ "avgResponseTime": {
+ "type": "number",
+ "nullable": true
+ },
+ "upCount": {
+ "type": "number"
+ },
+ "totalCount": {
+ "type": "number"
+ },
+ "avgRunning": {
+ "type": "number",
+ "nullable": true
+ },
+ "avgTotal": {
+ "type": "number",
+ "nullable": true
+ },
+ "avgUnhealthy": {
+ "type": "number",
+ "nullable": true
+ }
+ },
+ "required": ["_id", "avgResponseTime", "upCount", "totalCount", "avgRunning", "avgTotal", "avgUnhealthy"]
+ }
+ },
+ "latest": {
+ "type": "object",
+ "nullable": true,
+ "properties": {
+ "containers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string",
+ "enum": ["created", "running", "paused", "restarting", "removing", "exited", "dead"]
+ },
+ "status": {
+ "type": "string"
+ },
+ "health": {
+ "type": "string",
+ "enum": ["healthy", "unhealthy", "starting", "none"]
+ },
+ "cpuPct": {
+ "type": "number"
+ },
+ "memoryUsedBytes": {
+ "type": "number"
+ },
+ "memoryLimitBytes": {
+ "type": "number"
+ },
+ "memoryPct": {
+ "type": "number"
+ },
+ "restartCount": {
+ "type": "number"
+ },
+ "startedAt": {
+ "type": "string"
+ },
+ "ports": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "privatePort": {
+ "type": "number"
+ },
+ "protocol": {
+ "type": "string",
+ "enum": ["tcp", "udp", "sctp"]
+ },
+ "publicPort": {
+ "type": "number"
+ },
+ "hostIp": {
+ "type": "string"
+ }
+ },
+ "required": ["privatePort", "protocol"]
+ }
+ },
+ "mounts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string"
+ },
+ "destination": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "rw": {
+ "type": "boolean"
+ }
+ },
+ "required": ["type", "source", "destination", "mode", "rw"]
+ }
+ }
+ },
+ "required": ["id", "name", "image", "state", "status", "health"]
+ }
+ },
+ "summary": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "number"
+ },
+ "running": {
+ "type": "number"
+ },
+ "stopped": {
+ "type": "number"
+ },
+ "unhealthy": {
+ "type": "number"
+ }
+ },
+ "required": ["total", "running", "stopped", "unhealthy"]
+ },
+ "checkedAt": {
+ "type": "string"
+ }
+ },
+ "required": ["containers", "checkedAt"]
+ }
+ },
+ "required": ["aggregateData", "upChecks", "aggregate", "latest"]
+ },
+ "monitorStats": {
+ "type": "object",
+ "nullable": true,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "monitorId": {
+ "type": "string"
+ },
+ "avgResponseTime": {
+ "type": "number"
+ },
+ "maxResponseTime": {
+ "type": "number"
+ },
+ "totalChecks": {
+ "type": "number"
+ },
+ "totalUpChecks": {
+ "type": "number"
+ },
+ "totalDownChecks": {
+ "type": "number"
+ },
+ "uptimePercentage": {
+ "type": "number"
+ },
+ "lastCheckTimestamp": {
+ "type": "number"
+ },
+ "lastResponseTime": {
+ "type": "number"
+ },
+ "timeOfLastFailure": {
+ "type": "number"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "monitorId",
+ "avgResponseTime",
+ "maxResponseTime",
+ "totalChecks",
+ "totalUpChecks",
+ "totalDownChecks",
+ "uptimePercentage",
+ "lastCheckTimestamp",
+ "lastResponseTime",
+ "createdAt",
+ "updatedAt"
+ ]
+ }
+ },
+ "required": ["monitor", "stats", "monitorStats"]
+ }
+ },
+ "required": ["success", "msg", "data"]
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/monitors/docker/details/{monitorId}/containers/{containerName}": {
+ "get": {
+ "tags": ["monitors"],
+ "summary": "Get details for one container on a Docker host monitor",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "monitorId",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "containerName",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "enum": ["recent", "hour", "day", "week", "month", "all"]
+ },
+ "required": false,
+ "name": "dateRange",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "monitor": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"]
+ },
+ "url": {
+ "type": "string"
+ },
+ "port": {
+ "type": "number"
+ },
+ "isActive": {
+ "type": "boolean"
+ },
+ "interval": {
+ "type": "number"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"]
+ },
+ "statusWindowSize": {
+ "type": "number"
+ },
+ "statusWindowThreshold": {
+ "type": "number"
+ },
+ "ignoreTlsErrors": {
+ "type": "boolean"
+ },
+ "proxyMode": {
+ "type": "string",
+ "enum": ["inherit", "none", "custom"]
+ },
+ "proxyId": {
+ "type": "string"
+ },
+ "useAdvancedMatching": {
+ "type": "boolean"
+ },
+ "jsonPath": {
+ "type": "string"
+ },
+ "expectedValue": {
+ "type": "string"
+ },
+ "matchMethod": {
+ "type": "string",
+ "enum": ["equal", "include", "regex"]
+ },
+ "method": {
+ "type": "string",
+ "enum": ["GET", "HEAD"]
+ },
+ "notifications": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "customUpCodes": {
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ },
+ "secret": {
+ "type": "string"
+ },
+ "cpuAlertThreshold": {
+ "type": "number"
+ },
+ "memoryAlertThreshold": {
+ "type": "number"
+ },
+ "diskAlertThreshold": {
+ "type": "number"
+ },
+ "tempAlertThreshold": {
+ "type": "number"
+ },
+ "selectedDisks": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "gameId": {
+ "type": "string"
+ },
+ "grpcServiceName": {
+ "type": "string"
+ },
+ "group": {
+ "type": "string",
+ "nullable": true
+ },
+ "geoCheckEnabled": {
+ "type": "boolean"
+ },
+ "geoCheckLocations": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["EU", "NA", "AS", "SA", "AF", "OC"]
+ }
+ },
+ "geoCheckInterval": {
+ "type": "number"
+ },
+ "dockerLogsEnabled": {
+ "type": "boolean"
+ },
+ "dockerTlsCa": {
+ "type": "string"
+ },
+ "dockerTlsCert": {
+ "type": "string"
+ },
+ "dockerTlsKeySet": {
+ "type": "boolean"
+ },
+ "dnsServer": {
+ "type": "string"
+ },
+ "dnsRecordType": {
+ "type": "string",
+ "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]
+ },
+ "teamId": {
+ "type": "string"
+ },
+ "userId": {
+ "type": "string"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "lastEvaluatedAt": {
+ "type": "number"
+ },
+ "id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "name",
+ "type",
+ "url",
+ "isActive",
+ "interval",
+ "status",
+ "statusWindowSize",
+ "statusWindowThreshold",
+ "ignoreTlsErrors",
+ "proxyMode",
+ "useAdvancedMatching",
+ "method",
+ "notifications",
+ "tags",
+ "cpuAlertThreshold",
+ "memoryAlertThreshold",
+ "diskAlertThreshold",
+ "tempAlertThreshold",
+ "selectedDisks",
+ "geoCheckEnabled",
+ "geoCheckLocations",
+ "geoCheckInterval",
+ "dockerLogsEnabled",
+ "teamId",
+ "userId",
+ "createdAt",
+ "updatedAt",
+ "lastEvaluatedAt",
+ "id"
+ ],
+ "additionalProperties": {
+ "nullable": true
+ }
+ },
+ "stats": {
+ "type": "object",
+ "properties": {
+ "aggregate": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "_id": {
+ "type": "string"
+ },
+ "avgCpuPct": {
+ "type": "number",
+ "nullable": true
+ },
+ "avgMemoryUsedBytes": {
+ "type": "number",
+ "nullable": true
+ },
+ "avgMemoryPct": {
+ "type": "number",
+ "nullable": true
+ },
+ "minRestartCount": {
+ "type": "number",
+ "nullable": true
+ },
+ "maxRestartCount": {
+ "type": "number",
+ "nullable": true
+ }
+ },
+ "required": ["_id", "avgCpuPct", "avgMemoryUsedBytes", "avgMemoryPct", "minRestartCount", "maxRestartCount"]
+ }
+ },
+ "restartsInRange": {
+ "type": "number"
+ },
+ "latest": {
+ "type": "object",
+ "nullable": true,
+ "properties": {
+ "container": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "image": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string",
+ "enum": ["created", "running", "paused", "restarting", "removing", "exited", "dead"]
+ },
+ "status": {
+ "type": "string"
+ },
+ "health": {
+ "type": "string",
+ "enum": ["healthy", "unhealthy", "starting", "none"]
+ },
+ "cpuPct": {
+ "type": "number"
+ },
+ "memoryUsedBytes": {
+ "type": "number"
+ },
+ "memoryLimitBytes": {
+ "type": "number"
+ },
+ "memoryPct": {
+ "type": "number"
+ },
+ "restartCount": {
+ "type": "number"
+ },
+ "startedAt": {
+ "type": "string"
+ },
+ "ports": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "privatePort": {
+ "type": "number"
+ },
+ "protocol": {
+ "type": "string",
+ "enum": ["tcp", "udp", "sctp"]
+ },
+ "publicPort": {
+ "type": "number"
+ },
+ "hostIp": {
+ "type": "string"
+ }
+ },
+ "required": ["privatePort", "protocol"]
+ }
+ },
+ "mounts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string"
+ },
+ "destination": {
+ "type": "string"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "rw": {
+ "type": "boolean"
+ }
+ },
+ "required": ["type", "source", "destination", "mode", "rw"]
+ }
+ }
+ },
+ "required": ["id", "name", "image", "state", "status", "health"]
+ },
+ "checkedAt": {
+ "type": "string"
+ }
+ },
+ "required": ["container", "checkedAt"]
+ }
+ },
+ "required": ["aggregate", "restartsInRange", "latest"]
+ }
+ },
+ "required": ["monitor", "stats"]
+ }
+ },
+ "required": ["success", "msg", "data"]
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/monitors/docker/details/{monitorId}/containers/{containerName}/logs": {
+ "get": {
+ "tags": ["monitors"],
+ "summary": "Get stored log lines for one container on a Docker host monitor, newest first",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "monitorId",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "minLength": 1
+ },
+ "required": true,
+ "name": "containerName",
+ "in": "path"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "required": false,
+ "name": "before",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "required": false,
+ "name": "after",
+ "in": "query"
+ },
+ {
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "default": 20
+ },
+ "required": false,
+ "name": "limit",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [true]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "logs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "monitorId": {
+ "type": "string"
+ },
+ "teamId": {
+ "type": "string"
+ },
+ "containerId": {
+ "type": "string"
+ },
+ "containerName": {
+ "type": "string"
+ }
+ },
+ "required": ["monitorId", "teamId", "containerId", "containerName"]
+ },
+ "lines": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "ts": {
+ "type": "string"
+ },
+ "stream": {
+ "type": "string",
+ "enum": ["stdout", "stderr"]
+ },
+ "text": {
+ "type": "string"
+ }
+ },
+ "required": ["ts", "stream", "text"]
+ }
+ },
+ "gap": {
+ "type": "boolean"
+ },
+ "checkedAt": {
+ "type": "string"
+ },
+ "expiry": {
+ "type": "string"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "metadata", "lines", "gap", "checkedAt", "expiry", "createdAt", "updatedAt"]
+ }
+ },
+ "nextCursor": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "required": ["logs", "nextCursor"]
+ }
+ },
+ "required": ["success", "msg", "data"]
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "enum": [false]
+ },
+ "msg": {
+ "type": "string"
+ },
+ "data": {
+ "nullable": true
+ }
+ },
+ "required": ["success", "msg"]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/monitors/pagespeed/details/{monitorId}": {
"get": {
"tags": ["monitors"],
@@ -7960,6 +9305,42 @@
"type": "number",
"minimum": 300000
},
+ "dockerLogsEnabled": {
+ "type": "boolean"
+ },
+ "dockerTlsCa": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ]
+ },
+ "dockerTlsCert": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ]
+ },
+ "dockerTlsKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ]
+ },
"dnsServer": {
"type": "string",
"minLength": 1
@@ -8648,6 +10029,10 @@
"minimum": 300000,
"default": 300000
},
+ "dockerLogsEnabled": {
+ "type": "boolean",
+ "default": false
+ },
"dnsServer": {
"type": "string",
"minLength": 1
@@ -9212,6 +10597,42 @@
"type": "number",
"minimum": 300000
},
+ "dockerLogsEnabled": {
+ "type": "boolean"
+ },
+ "dockerTlsCa": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ]
+ },
+ "dockerTlsCert": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ]
+ },
+ "dockerTlsKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "string",
+ "enum": [""]
+ }
+ ]
+ },
"dnsServer": {
"type": "string",
"minLength": 1
diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts
index ddb207eb8..533f9f9c3 100644
--- a/server/openapi/routes/monitor.ts
+++ b/server/openapi/routes/monitor.ts
@@ -16,8 +16,16 @@ import {
importMonitorsBodyValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
+ getDockerDetailsByIdParamValidation,
+ getDockerDetailsByIdQueryValidation,
+ getDockerContainerNameParamValidation,
+ getDockerContainerByNameQueryValidation,
+ dockerDetailsResponseSchema,
+ dockerContainerDetailsResponseSchema,
monitorResponseSchema,
uptimeDetailsResponseSchema,
+ getDockerContainerLogsQueryValidation,
+ dockerContainerLogsResponseSchema,
} from "@/api/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js";
@@ -105,6 +113,36 @@ registry.registerPath({
responses: { "200": okUnknown, ...standardErrors },
});
+registry.registerPath({
+ method: "get",
+ path: "/monitors/docker/details/{monitorId}",
+ tags,
+ summary: "Get Docker host details for a monitor",
+ security: bearer,
+ request: { params: getDockerDetailsByIdParamValidation, query: getDockerDetailsByIdQueryValidation },
+ responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors },
+});
+
+registry.registerPath({
+ method: "get",
+ path: "/monitors/docker/details/{monitorId}/containers/{containerName}",
+ tags,
+ summary: "Get details for one container on a Docker host monitor",
+ security: bearer,
+ request: { params: getDockerContainerNameParamValidation, query: getDockerContainerByNameQueryValidation },
+ responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors },
+});
+
+registry.registerPath({
+ method: "get",
+ path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs",
+ tags,
+ summary: "Get stored log lines for one container on a Docker host monitor, newest first",
+ security: bearer,
+ request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation },
+ responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors },
+});
+
registry.registerPath({
method: "get",
path: "/monitors/pagespeed/details/{monitorId}",
diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts
index 5950ab4a3..970d5f598 100644
--- a/server/openapi/routes/notification.ts
+++ b/server/openapi/routes/notification.ts
@@ -65,6 +65,10 @@ const notificationVariantMeta: Record {
const newUser = req.body.user;
- const newUserToken = req.body.token;
+ const newUserToken = registerInviteTokenValidation.parse(req.body.token);
if (newUser?.email) {
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts
index f05e0df5c..12e8f8520 100644
--- a/server/src/api/controllers/monitorController.ts
+++ b/server/src/api/controllers/monitorController.ts
@@ -17,6 +17,11 @@ import {
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
bulkPauseMonitorBodyValidation,
+ getDockerDetailsByIdParamValidation,
+ getDockerDetailsByIdQueryValidation,
+ getDockerContainerNameParamValidation,
+ getDockerContainerByNameQueryValidation,
+ getDockerContainerLogsQueryValidation,
} from "@/api/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import * as whoiser from "whoiser";
@@ -31,6 +36,9 @@ export interface IMonitorController {
getUptimeDetailsById: RequestHandler;
getHardwareDetailsById: RequestHandler;
getPageSpeedDetailsById: RequestHandler;
+ getDockerDetailsById: RequestHandler;
+ getDockerContainerByName: RequestHandler;
+ getDockerContainerLogs: RequestHandler;
getGeoChecksByMonitorId: RequestHandler;
getMonitorById: RequestHandler;
createMonitor: RequestHandler;
@@ -149,6 +157,73 @@ class MonitorController implements IMonitorController {
});
});
+ getDockerDetailsById = catchAsync(async (req: Request, res: Response) => {
+ const validatedParams = getDockerDetailsByIdParamValidation.parse(req.params);
+ const validatedQuery = getDockerDetailsByIdQueryValidation.parse(req.query);
+
+ const monitorId = validatedParams.monitorId;
+ const dateRange = validatedQuery.dateRange || "recent";
+ const teamId = requireTeamId(req.user?.teamId);
+
+ const data = await this.monitorService.getDockerDetailsById({
+ teamId,
+ monitorId,
+ dateRange,
+ });
+
+ return res.status(200).json({
+ success: true,
+ msg: "Docker details retrieved successfully",
+ data: data,
+ });
+ });
+
+ getDockerContainerByName = catchAsync(async (req: Request, res: Response) => {
+ const validatedParams = getDockerContainerNameParamValidation.parse(req.params);
+ const validatedQuery = getDockerContainerByNameQueryValidation.parse(req.query);
+
+ const { monitorId, containerName } = validatedParams;
+ const dateRange = validatedQuery.dateRange || "recent";
+ const teamId = requireTeamId(req.user?.teamId);
+
+ const data = await this.monitorService.getDockerContainerByName({
+ teamId,
+ monitorId,
+ containerName,
+ dateRange,
+ });
+
+ return res.status(200).json({
+ success: true,
+ msg: "Docker container retrieved successfully",
+ data,
+ });
+ });
+
+ getDockerContainerLogs = catchAsync(async (req: Request, res: Response) => {
+ const validatedParams = getDockerContainerNameParamValidation.parse(req.params);
+ const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query);
+
+ const { monitorId, containerName } = validatedParams;
+ const { before, after, limit } = validatedQuery;
+ const teamId = requireTeamId(req.user?.teamId);
+
+ const data = await this.monitorService.getDockerContainerLogs({
+ teamId,
+ monitorId,
+ containerName,
+ before: before ? new Date(before) : undefined,
+ after: after ? new Date(after) : undefined,
+ limit,
+ });
+
+ return res.status(200).json({
+ success: true,
+ msg: "Docker container logs retrieved successfully",
+ data,
+ });
+ });
+
getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => {
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
diff --git a/server/src/api/routes/checkRoutes.ts b/server/src/api/routes/checkRoutes.ts
index 97974f9df..99c6f8a07 100644
--- a/server/src/api/routes/checkRoutes.ts
+++ b/server/src/api/routes/checkRoutes.ts
@@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router =>
router.get("/team", checkController.getChecksByTeam);
router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId);
router.get("/:monitorId", checkController.getChecksByMonitor);
- router.delete("/:monitorId", checkController.deleteChecks);
+ router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks);
return router;
};
diff --git a/server/src/api/routes/maintenanceWindowRoutes.ts b/server/src/api/routes/maintenanceWindowRoutes.ts
index 22c3fd94a..6796d1a72 100644
--- a/server/src/api/routes/maintenanceWindowRoutes.ts
+++ b/server/src/api/routes/maintenanceWindowRoutes.ts
@@ -1,15 +1,16 @@
import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js";
+import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => {
const router = Router();
- router.post("/", maintenanceWindowController.createMaintenanceWindows);
+ router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows);
router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId);
router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId);
router.get("/:id", maintenanceWindowController.getMaintenanceWindowById);
- router.patch("/:id", maintenanceWindowController.editMaintenanceWindow);
- router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow);
+ router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow);
+ router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow);
return router;
};
diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts
index 4cd90a9a0..7e3a4230f 100644
--- a/server/src/api/routes/monitorRoutes.ts
+++ b/server/src/api/routes/monitorRoutes.ts
@@ -18,6 +18,15 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout
// PageSpeed routes
router.get("/pagespeed/details/:monitorId", monitorController.getPageSpeedDetailsById);
+ // Docker routes
+ router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById);
+ router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName);
+ router.get(
+ "/docker/details/:monitorId/containers/:containerName/logs",
+ isAllowed(["admin", "superadmin"]),
+ monitorController.getDockerContainerLogs
+ );
+
// Geo checks routes
router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId);
diff --git a/server/src/api/routes/notificationRoutes.ts b/server/src/api/routes/notificationRoutes.ts
index 32ea1923c..e2d7bfc86 100644
--- a/server/src/api/routes/notificationRoutes.ts
+++ b/server/src/api/routes/notificationRoutes.ts
@@ -1,14 +1,15 @@
import { INotificationController } from "@/api/controllers/notificationController.js";
+import { isAllowed } from "@/api/middleware/isAllowed.js";
import { Router } from "express";
export const createNotificationRoutes = (notificationController: INotificationController): Router => {
const router = Router();
- router.post("/", notificationController.createNotification);
- router.post("/test/all", notificationController.testAllNotifications);
- router.post("/test", notificationController.testNotification);
+ router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification);
+ router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications);
+ router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification);
router.get("/team", notificationController.getNotificationsByTeamId);
router.get("/:id", notificationController.getNotificationById);
- router.delete("/:id", notificationController.deleteNotification);
- router.patch("/:id", notificationController.editNotification);
+ router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification);
+ router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification);
return router;
};
diff --git a/server/src/api/validation/authValidation.ts b/server/src/api/validation/authValidation.ts
index be30272e6..9a3162d91 100644
--- a/server/src/api/validation/authValidation.ts
+++ b/server/src/api/validation/authValidation.ts
@@ -23,6 +23,8 @@ export const registrationBodyValidation = z.object({
inviteToken: z.string().optional().default(""),
});
+export const registerInviteTokenValidation = z.string().optional().default("");
+
export const recoveryValidation = z.object({
email: z.email("Must be a valid email address"),
});
diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts
index e694a216d..0f90aef99 100644
--- a/server/src/api/validation/monitorValidation.ts
+++ b/server/src/api/validation/monitorValidation.ts
@@ -13,6 +13,11 @@ import {
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
+import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
+import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
+import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
+import { X509Certificate } from "node:crypto";
+import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -118,6 +123,60 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
}
};
+const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
+ if (data.type !== "docker" || data.url === undefined) return;
+ if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["url"],
+ message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]",
+ });
+ }
+};
+
+type DockerTlsFields = {
+ type?: string;
+ url?: string;
+ ignoreTlsErrors?: boolean;
+ dockerTlsCa?: string;
+ dockerTlsCert?: string;
+ dockerTlsKey?: string;
+};
+
+const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => {
+ if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return;
+ const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
+
+ if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host");
+
+ if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host");
+
+ if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored");
+
+ let certificate: X509Certificate | undefined;
+
+ try {
+ if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa);
+ } catch (error: unknown) {
+ issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error");
+ }
+
+ try {
+ if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert);
+ } catch (error) {
+ issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error");
+ }
+
+ try {
+ if (data.dockerTlsKey) {
+ const key = parsePrivateKey(data.dockerTlsKey);
+ if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate");
+ }
+ } catch (error: unknown) {
+ issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error");
+ }
+};
+
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -154,6 +213,10 @@ export const createMonitorBodyValidation = z
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
+ dockerLogsEnabled: z.boolean().optional(),
+ dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
+ dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
+ dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -161,7 +224,9 @@ export const createMonitorBodyValidation = z
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
- .superRefine(refineProxySelection);
+ .superRefine(refineProxySelection)
+ .superRefine(refineDockerUrl)
+ .superRefine(refineDockerTls("create"));
export const editMonitorBodyValidation = z
.object({
@@ -197,6 +262,10 @@ export const editMonitorBodyValidation = z
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
+ dockerLogsEnabled: z.boolean().optional(),
+ dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
+ dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
+ dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -204,7 +273,9 @@ export const editMonitorBodyValidation = z
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
- .superRefine(refineProxySelection);
+ .superRefine(refineProxySelection)
+ .superRefine(refineDockerUrl)
+ .superRefine(refineDockerTls("edit"));
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -271,6 +342,7 @@ const importedMonitorSchema = z
geoCheckEnabled: z.boolean().default(false),
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
+ dockerLogsEnabled: z.boolean().default(false),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
createdAt: z.string().optional(),
@@ -280,7 +352,8 @@ const importedMonitorSchema = z
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
- .superRefine(refineProxySelection);
+ .superRefine(refineProxySelection)
+ .superRefine(refineDockerUrl);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
@@ -296,6 +369,30 @@ export const getHardwareDetailsByIdQueryValidation = z.object({
dateRange: z.enum(DateRanges).optional(),
});
+export const getDockerDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required") });
+export const getDockerDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
+
+export const getDockerContainerNameParamValidation = z.object({
+ monitorId: z.string().min(1, "Monitor ID is required"),
+ containerName: z.string().min(1, "Container name is required"),
+});
+export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() });
+
+export const getDockerContainerLogsQueryValidation = z
+ .object({
+ before: z.iso.datetime().optional(),
+ after: z.iso.datetime().optional(),
+ limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT),
+ })
+ .superRefine((query, ctx) => {
+ if (query.before && query.after) {
+ ctx.addIssue({
+ code: "custom",
+ message: "Specify either before or after, not both",
+ path: ["after"],
+ });
+ }
+ });
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
// what the controllers actually serialize.
export const monitorResponseSchema = z
@@ -334,6 +431,10 @@ export const monitorResponseSchema = z
geoCheckEnabled: z.boolean(),
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
+ dockerLogsEnabled: z.boolean(),
+ dockerTlsCa: z.string().optional(),
+ dockerTlsCert: z.string().optional(),
+ dockerTlsKeySet: z.boolean().optional(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
teamId: z.string(),
@@ -392,3 +493,126 @@ export const uptimeDetailsResponseSchema = z.object({
}),
monitorStats: monitorStatsResponseSchema.nullable(),
});
+
+// Keep aligned with DockerContainerPort / DockerContainerMount in types/network.ts.
+export const dockerContainerPortResponseSchema = z.object({
+ privatePort: z.number(),
+ protocol: z.enum(DockerPortProtocols),
+ publicPort: z.number().optional(),
+ hostIp: z.string().optional(),
+});
+
+export const dockerContainerMountResponseSchema = z.object({
+ type: z.string(),
+ name: z.string().optional(),
+ source: z.string(),
+ destination: z.string(),
+ mode: z.string(),
+ rw: z.boolean(),
+});
+
+// Keep aligned with DockerContainerInfo / DockerContainerSummary in types/network.ts.
+export const dockerContainerResponseSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ image: z.string(),
+ state: z.enum(DockerContainerStates),
+ status: z.string(),
+ health: z.enum(DockerHealthStatuses),
+ cpuPct: z.number().optional(),
+ memoryUsedBytes: z.number().optional(),
+ memoryLimitBytes: z.number().optional(),
+ memoryPct: z.number().optional(),
+ restartCount: z.number().optional(),
+ startedAt: z.string().optional(),
+ ports: z.array(dockerContainerPortResponseSchema).optional(),
+ mounts: z.array(dockerContainerMountResponseSchema).optional(),
+});
+
+export const containerSummaryResponseSchema = z.object({
+ total: z.number(),
+ running: z.number(),
+ stopped: z.number(),
+ unhealthy: z.number(),
+});
+
+// Keep aligned with DockerStatsBucket in domain/checks/check.type.ts. The avg fields are
+// null for buckets with no values ($avg skips missing; down checks store no containerSummary).
+export const dockerStatsBucketResponseSchema = z.object({
+ _id: z.string(),
+ avgResponseTime: z.number().nullable(),
+ upCount: z.number(),
+ totalCount: z.number(),
+ avgRunning: z.number().nullable(),
+ avgTotal: z.number().nullable(),
+ avgUnhealthy: z.number().nullable(),
+});
+
+// Response of GET /monitors/docker/details/{monitorId}. Keep aligned with
+// DockerDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the
+// repository's domain entity, which serializes `id` rather than `_id`.
+export const dockerDetailsResponseSchema = z.object({
+ monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }),
+ stats: z.object({
+ aggregateData: z.object({ totalChecks: z.number() }),
+ upChecks: z.object({ totalChecks: z.number() }),
+ aggregate: z.array(dockerStatsBucketResponseSchema),
+ latest: z
+ .object({
+ containers: z.array(dockerContainerResponseSchema),
+ summary: containerSummaryResponseSchema.optional(),
+ checkedAt: z.string(),
+ })
+ .nullable(),
+ }),
+ monitorStats: monitorStatsResponseSchema.nullable(),
+});
+
+// Keep aligned with DockerContainerStatsBucket in domain/checks/check.type.ts.
+export const dockerContainerStatsBucketResponseSchema = z.object({
+ _id: z.string(),
+ avgCpuPct: z.number().nullable(),
+ avgMemoryUsedBytes: z.number().nullable(),
+ avgMemoryPct: z.number().nullable(),
+ minRestartCount: z.number().nullable(),
+ maxRestartCount: z.number().nullable(),
+});
+
+// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}. Keep
+// aligned with DockerContainerDetailsResult in domain/monitors/monitor.type.ts.
+export const dockerContainerDetailsResponseSchema = z.object({
+ monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }),
+ stats: z.object({
+ aggregate: z.array(dockerContainerStatsBucketResponseSchema),
+ restartsInRange: z.number(),
+ latest: z
+ .object({
+ container: dockerContainerResponseSchema,
+ checkedAt: z.string(),
+ })
+ .nullable(),
+ }),
+});
+
+export const dockerLogLineResponseSchema = z.object({
+ ts: z.string(),
+ stream: z.enum(DockerLogStreams),
+ text: z.string(),
+});
+
+export const dockerLogResponseSchema = z.object({
+ id: z.string(),
+ metadata: z.object({ monitorId: z.string(), teamId: z.string(), containerId: z.string(), containerName: z.string() }),
+ lines: z.array(dockerLogLineResponseSchema),
+ gap: z.boolean(),
+ checkedAt: z.string(),
+ expiry: z.string(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+});
+
+// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}/logs.
+export const dockerContainerLogsResponseSchema = z.object({
+ logs: z.array(dockerLogResponseSchema),
+ nextCursor: z.string().nullable(),
+});
diff --git a/server/src/api/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts
index 405ec8e08..10d65573b 100644
--- a/server/src/api/validation/notificationValidation.ts
+++ b/server/src/api/validation/notificationValidation.ts
@@ -123,6 +123,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
}),
+ // Signalgrid notification
+ z.object({
+ notificationName: z.string().min(1, "Notification name is required"),
+ type: z.literal("signalgrid"),
+ address: z.string().min(1, "Channel is required"),
+ accessToken: z.string().min(1, "Client key is required"),
+ }),
// Twilio SMS notification
z.object({
notificationName: z.string().min(1, "Notification name is required"),
diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts
index ad2277797..e92f28a57 100644
--- a/server/src/api/validation/statusPageValidation.ts
+++ b/server/src/api/validation/statusPageValidation.ts
@@ -112,6 +112,7 @@ export const checkSnapshotResponseSchema = z
bestPractices: z.number().optional(),
seo: z.number().optional(),
performance: z.number().optional(),
+ containerSummary: z.object({ total: z.number(), running: z.number(), stopped: z.number(), unhealthy: z.number() }).optional(),
})
.passthrough();
diff --git a/server/src/config/envValidation.ts b/server/src/config/envValidation.ts
index a769a28c7..3c1b85164 100644
--- a/server/src/config/envValidation.ts
+++ b/server/src/config/envValidation.ts
@@ -3,6 +3,39 @@ import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settin
import { booleanCoercion } from "@/api/validation/shared.js";
import { ILogger } from "@/utils/logger.js";
+// Padded standard base64 of exactly 32 bytes, as produced by `openssl rand -base64 32`
+const ENCRYPTION_KEY_ENTRY = /^[A-Za-z0-9+/]{43}=$/;
+
+export const encryptionKeyList = z
+ .string()
+ .optional()
+ .transform((raw, ctx) => {
+ if (raw === undefined) return [];
+ const entries = raw
+ .split(",")
+ .map((entry, index) => ({ entry: entry.trim(), slot: index + 1 }))
+ .filter(({ entry }) => entry.length > 0);
+ const seen = new Set();
+ const keys: Buffer[] = [];
+ entries.forEach(({ entry, slot }) => {
+ if (!ENCRYPTION_KEY_ENTRY.test(entry)) {
+ ctx.addIssue({
+ code: "custom",
+ message: `ENCRYPTION_KEY entry ${slot} must be 32 bytes as padded standard base64 (openssl rand -base64 32)`,
+ });
+ return;
+ }
+ const decoded = Buffer.from(entry, "base64");
+ const fingerprint = decoded.toString("hex");
+ if (seen.has(fingerprint)) {
+ ctx.addIssue({ code: "custom", message: `ENCRYPTION_KEY entry ${slot} duplicates an earlier entry` });
+ }
+ seen.add(fingerprint);
+ keys.push(decoded);
+ });
+ return keys;
+ });
+
const envSchema = z.object({
// Server Configuration
PORT: z.string().default("52345"),
@@ -36,6 +69,9 @@ const envSchema = z.object({
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
+
+ // Encryption
+ ENCRYPTION_KEY: encryptionKeyList,
});
export type ValidatedEnv = z.infer;
diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts
index 5ce76aa3d..c5f58ac19 100644
--- a/server/src/config/services.api.ts
+++ b/server/src/config/services.api.ts
@@ -35,6 +35,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorsRepository,
checksRepository,
geoChecksRepository,
+ dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
@@ -75,9 +76,11 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorsRepository,
checksRepository,
geoChecksRepository,
+ dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
+ encryptionService: shared.encryptionService,
});
const maintenanceWindowService = new MaintenanceWindowService({
diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts
index 7ea873c4b..3b4fdb147 100644
--- a/server/src/config/services.shared.ts
+++ b/server/src/config/services.shared.ts
@@ -1,6 +1,6 @@
import { Mongoose } from "mongoose";
import { hostname } from "node:os";
-import { randomUUID } from "node:crypto";
+import * as nodeCrypto from "node:crypto";
import fs from "fs";
import path from "path";
import nodemailer from "nodemailer";
@@ -13,12 +13,14 @@ import { IDb } from "@/db/db.interface.js";
import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js";
import { ICheckService, CheckService } from "@/domain/checks/check.service.js";
import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js";
+import { IDockerLogsService, DockerLogsService } from "@/domain/docker/docker-log.service.js";
import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js";
import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js";
import { IEmailService, EmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
+import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
@@ -32,6 +34,7 @@ import { MatrixProvider } from "@/domain/notifications/providers/matrix.js";
import { TeamsProvider } from "@/domain/notifications/providers/teams.js";
import { TelegramProvider } from "@/domain/notifications/providers/telegram.js";
import { PushoverProvider } from "@/domain/notifications/providers/pushover.js";
+import { SignalgridProvider } from "@/domain/notifications/providers/signalgrid.js";
import { TwilioProvider } from "@/domain/notifications/providers/twilio.js";
import { NtfyProvider } from "@/domain/notifications/providers/ntfy.js";
@@ -58,6 +61,7 @@ import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js";
import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js";
+import MongoDockerLogsRepository from "@/domain/docker/docker-log.repository.mongo.js";
import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js";
import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js";
import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js";
@@ -72,6 +76,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
+import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
@@ -79,10 +84,12 @@ export interface SharedServices {
db: IDb;
settingsService: ISettingsService;
emailService: IEmailService;
+ encryptionService: IEncryptionService;
notificationMessageBuilder: INotificationMessageBuilder;
incidentService: IIncidentService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
+ dockerLogsService: IDockerLogsService;
notificationsService: INotificationsService;
// Queue identity (one per process; the worker reuses these)
@@ -94,6 +101,7 @@ export interface SharedServices {
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
+ dockerLogsRepository: IDockerLogsRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -112,13 +120,18 @@ export const buildShared = async ({
logger,
envSettings,
settingsService,
+ encryptionKeys,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
+ encryptionKeys: Buffer[];
}): Promise => {
// Shared worker ID
- const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
+ const workerId = `${hostname()}:${process.pid}:${nodeCrypto.randomUUID()}`;
+
+ // Create the encryption service
+ const encryptionService = new EncryptionService(encryptionKeys, logger, nodeCrypto);
// Create DB
let db: IDb | null = null;
@@ -131,6 +144,7 @@ export const buildShared = async ({
const incidentsRepository = new MongoIncidentsRepository();
const usersRepository = new MongoUsersRepository();
const geoChecksRepository = new MongoGeoChecksRepository();
+ const dockerLogsRepository = new MongoDockerLogsRepository();
const notificationsRepository = new MongoNotificationsRepository();
const jobsRepository = new MongoJobsRepository(workerId);
const queueWorkersRepository = new MongoQueueWorkersRepository();
@@ -161,6 +175,7 @@ export const buildShared = async ({
monitorsRepository,
});
+ const dockerLogsService = new DockerLogsService({ logger, dockerLogsRepository });
const webhookProvider = new WebhookProvider(logger);
const rocketChatProvider = new RocketChatProvider(logger);
const slackProvider = new SlackProvider(logger);
@@ -171,6 +186,7 @@ export const buildShared = async ({
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
+ const signalgridProvider = new SignalgridProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const ntfyProvider = new NtfyProvider(logger);
@@ -185,6 +201,7 @@ export const buildShared = async ({
teams: teamsProvider,
telegram: telegramProvider,
pushover: pushoverProvider,
+ signalgrid: signalgridProvider,
twilio: twilioProvider,
ntfy: ntfyProvider,
};
@@ -203,10 +220,12 @@ export const buildShared = async ({
db,
settingsService,
emailService,
+ encryptionService,
notificationMessageBuilder,
incidentService,
checkService,
geoChecksService,
+ dockerLogsService,
notificationsService,
workerId,
jobsRepository,
@@ -214,6 +233,7 @@ export const buildShared = async ({
queueWorkersRepository,
checksRepository,
geoChecksRepository,
+ dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts
index 9b4ea8609..acd4a944a 100644
--- a/server/src/config/services.worker.ts
+++ b/server/src/config/services.worker.ts
@@ -16,6 +16,7 @@ import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
+import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
@@ -39,6 +40,7 @@ import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
+import { AppError } from "@/utils/AppError.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
@@ -52,14 +54,17 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
settingsService,
checkService,
geoChecksService,
+ dockerLogsService,
notificationsService,
incidentService,
workerId,
jobsRepository,
queueWorkersRepository,
monitorsRepository,
+ encryptionService,
checksRepository,
geoChecksRepository,
+ dockerLogsRepository,
monitorStatsRepository,
incidentsRepository,
teamsRepository,
@@ -74,7 +79,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
- const dockerProvider = new DockerProvider(logger, Docker);
+ const dockerProvider = new DockerProvider(logger, Docker, encryptionService);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
@@ -95,7 +100,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
]);
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
- const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
+ const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
@@ -119,6 +124,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
networkService,
proxyResolver,
bufferService,
+ dockerLogsService,
logger
);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
@@ -138,7 +144,8 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
monitorStatsRepository,
checksRepository,
incidentsRepository,
- geoChecksRepository
+ geoChecksRepository,
+ dockerLogsRepository
);
const worker = await DBQueueWorker.create({
@@ -160,5 +167,23 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
workerId,
});
+ const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger);
+ try {
+ const result = await secretsRotationService.run();
+ logger.debug({
+ message: "Secrets rotation run successfully",
+ service: "WorkerServices",
+ method: "buildWorker",
+ details: { ...result },
+ });
+ } catch (error: unknown) {
+ logger.warn({
+ message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`,
+ service: "WorkerServices",
+ method: "buildWorker",
+ details: { ...(error instanceof AppError ? error.details : {}) },
+ });
+ }
+
return { worker, networkService, bufferService, statusService };
};
diff --git a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts
new file mode 100644
index 000000000..d4d3bd143
--- /dev/null
+++ b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts
@@ -0,0 +1,36 @@
+import { MonitorModel } from "../../domain/monitors/monitor.model.js";
+import type { ILogger } from "@/utils/logger.js";
+
+/**
+ * Rewrite docker monitor urls from the old container-name semantics to host urls.
+ *
+ * The old docker provider interpreted monitor.url as a container name/ID and only
+ * ever talked to the local socket, so every pre-existing docker monitor's host is
+ * definitionally unix:///var/run/docker.sock. The container name is preserved in
+ * the description. Urls already in host form (unix:// or an absolute socket
+ * path) are left untouched, which makes the migration idempotent.
+ */
+export async function migrateDockerMonitorUrls(logger: ILogger): Promise {
+ const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls";
+
+ try {
+ logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" });
+
+ const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [
+ {
+ $set: {
+ description: {
+ $concat: [{ $ifNull: ["$description", ""] }, " (was container: ", "$url", ")"],
+ },
+ url: "unix:///var/run/docker.sock",
+ },
+ },
+ ]);
+
+ logger.info({ service: SERVICE_NAME, message: `Migrated ${result.modifiedCount} docker monitors to host urls` });
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ logger.error({ service: SERVICE_NAME, message: `Error migrating docker monitor urls: ${errorMessage}` });
+ throw error;
+ }
+}
diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts
index 94829241d..c4233948c 100644
--- a/server/src/db/migration/index.ts
+++ b/server/src/db/migration/index.ts
@@ -10,6 +10,7 @@ import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvalua
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import { slimRecentChecks } from "./0010_slimRecentChecks.js";
import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js";
+import { migrateDockerMonitorUrls } from "./0012_migrateDockerMonitorUrls.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -29,6 +30,7 @@ const migrations: MigrationEntry[] = [
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
{ name: "0010_slimRecentChecks", execute: slimRecentChecks },
{ name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode },
+ { name: "0012_migrateDockerMonitorUrls", execute: migrateDockerMonitorUrls },
];
const runMigrations = async (logger: ILogger) => {
diff --git a/server/src/domain/checks/check.docker.aggregation.ts b/server/src/domain/checks/check.docker.aggregation.ts
new file mode 100644
index 000000000..37d6d8e87
--- /dev/null
+++ b/server/src/domain/checks/check.docker.aggregation.ts
@@ -0,0 +1,94 @@
+import CheckModel from "@/domain/checks/check.model.js";
+import { DockerContainerStatsBucket, DockerStatsBucket } from "@/domain/checks/check.type.js";
+import mongoose from "mongoose";
+
+type DateRange = { start: Date; end: Date };
+
+export const getDockerTotalChecks = async (monitorId: string, dates: DateRange): Promise =>
+ CheckModel.countDocuments({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.type": "docker",
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ });
+
+export const getDockerUpChecks = async (monitorId: string, dates: DateRange): Promise<{ totalChecks: number }> => {
+ const count = await CheckModel.countDocuments({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.type": "docker",
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ status: true,
+ });
+ return { totalChecks: count };
+};
+
+export const getDockerStats = async (monitorId: string, dates: DateRange, dateString: string): Promise =>
+ CheckModel.aggregate([
+ {
+ $match: {
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.type": "docker",
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ },
+ },
+ {
+ $group: {
+ _id: { $dateToString: { format: dateString, date: "$createdAt" } },
+ avgResponseTime: { $avg: "$responseTime" },
+ upCount: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } },
+ totalCount: { $sum: 1 },
+ avgRunning: { $avg: "$containerSummary.running" },
+ avgTotal: { $avg: "$containerSummary.total" },
+ avgUnhealthy: { $avg: "$containerSummary.unhealthy" },
+ },
+ },
+ { $sort: { _id: 1 } },
+ ]);
+
+export const getDockerLatestCheck = async (monitorId: string) =>
+ CheckModel.findOne({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.type": "docker",
+ })
+ .sort({ createdAt: -1 })
+ .select("containers containerSummary createdAt")
+ .lean();
+
+export const getDockerContainerStats = async (
+ monitorId: string,
+ containerName: string,
+ dates: DateRange,
+ dateString: string
+): Promise =>
+ CheckModel.aggregate([
+ {
+ $match: {
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.type": "docker",
+ createdAt: { $gte: dates.start, $lte: dates.end },
+ "containers.name": containerName,
+ },
+ },
+ { $unwind: "$containers" },
+ { $match: { "containers.name": containerName } },
+ {
+ $group: {
+ _id: { $dateToString: { format: dateString, date: "$createdAt" } },
+ avgCpuPct: { $avg: "$containers.cpuPct" },
+ avgMemoryUsedBytes: { $avg: "$containers.memoryUsedBytes" },
+ avgMemoryPct: { $avg: "$containers.memoryPct" },
+ minRestartCount: { $min: "$containers.restartCount" },
+ maxRestartCount: { $max: "$containers.restartCount" },
+ },
+ },
+ { $sort: { _id: 1 } },
+ ]);
+
+export const getDockerContainerLatestCheck = async (monitorId: string, containerName: string) =>
+ CheckModel.findOne({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.type": "docker",
+ "containers.name": containerName,
+ })
+ .sort({ createdAt: -1 })
+ .select("containers createdAt")
+ .lean();
diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts
index 9f3c2fbc1..a4319056e 100644
--- a/server/src/domain/checks/check.model.ts
+++ b/server/src/domain/checks/check.model.ts
@@ -14,6 +14,15 @@ import type {
GotTimings,
ILighthouseAudit,
} from "@/domain/checks/check.type.js";
+import {
+ DockerContainerInfo,
+ DockerContainerMount,
+ DockerContainerPort,
+ DockerContainerStates,
+ DockerContainerSummary,
+ DockerHealthStatuses,
+ DockerPortProtocols,
+} from "@/domain/docker/docker.type.js";
type CheckMetadataDocument = Omit & {
monitorId: Types.ObjectId;
@@ -172,6 +181,58 @@ const auditsSchema = new Schema(
{ _id: false }
);
+const dockerContainerPortSchema = new Schema(
+ {
+ privatePort: { type: Number, required: true },
+ protocol: { type: String, enum: DockerPortProtocols, default: "tcp" },
+ publicPort: { type: Number },
+ hostIp: { type: String },
+ },
+ { _id: false }
+);
+
+const dockerContainerMountSchema = new Schema(
+ {
+ type: { type: String, default: "" },
+ name: { type: String },
+ source: { type: String, default: "" },
+ destination: { type: String, default: "" },
+ mode: { type: String, default: "" },
+ rw: { type: Boolean, default: true },
+ },
+ { _id: false }
+);
+
+const dockerContainerSchema = new Schema(
+ {
+ id: { type: String, required: true },
+ name: { type: String, default: "" },
+ image: { type: String, default: "" },
+ state: { type: String, enum: DockerContainerStates, default: "created" },
+ status: { type: String, default: "" },
+ health: { type: String, enum: DockerHealthStatuses, default: "none" },
+ cpuPct: { type: Number },
+ memoryUsedBytes: { type: Number },
+ memoryLimitBytes: { type: Number },
+ memoryPct: { type: Number },
+ restartCount: { type: Number },
+ startedAt: { type: String },
+ ports: { type: [dockerContainerPortSchema], default: undefined },
+ mounts: { type: [dockerContainerMountSchema], default: undefined },
+ },
+ { _id: false }
+);
+
+const containerSummarySchema = new Schema(
+ {
+ total: { type: Number, default: 0 },
+ running: { type: Number, default: 0 },
+ stopped: { type: Number, default: 0 },
+ unhealthy: { type: Number, default: 0 },
+ },
+ { _id: false }
+);
+
const metadataSchema = new Schema(
{
monitorId: {
@@ -268,6 +329,14 @@ const CheckSchema = new Schema(
type: auditsSchema,
default: undefined,
},
+ containers: {
+ type: [dockerContainerSchema],
+ default: undefined,
+ },
+ containerSummary: {
+ type: containerSummarySchema,
+ default: undefined,
+ },
},
{
timestamps: true,
@@ -291,5 +360,5 @@ CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 });
const CheckModel = model("Check", CheckSchema);
export type { CheckDocument, CheckMetadataDocument };
-export { CheckModel };
+export { CheckModel, containerSummarySchema };
export default CheckModel;
diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts
index 1767b2ea1..c48c126d1 100644
--- a/server/src/domain/checks/check.repository.interface.ts
+++ b/server/src/domain/checks/check.repository.interface.ts
@@ -3,6 +3,8 @@ import type {
ChecksQueryResult,
ChecksSummary,
DailyCheckBucket,
+ DockerChecksResult,
+ DockerContainerStats,
HardwareChecksResult,
PageSpeedChecksResult,
UptimeChecksResult,
@@ -38,7 +40,8 @@ export interface IChecksRepository {
monitorId: string,
dateRange: DateRange,
options?: { type?: MonitorType }
- ): Promise;
+ ): Promise;
+ findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise;
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise;
diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts
index e64113b6c..23106c0b7 100644
--- a/server/src/domain/checks/check.repository.mongo.ts
+++ b/server/src/domain/checks/check.repository.mongo.ts
@@ -10,6 +10,7 @@ import type {
CheckMemoryInfo,
CheckMetadata,
CheckNetworkInterfaceInfo,
+ DockerContainerStats,
GotTimings,
HardwareCheckStats,
} from "@/domain/checks/check.type.js";
@@ -24,6 +25,14 @@ import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@
import { CheckFilter, DateRange } from "@/types/query.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR } from "@/types/network.js";
+import {
+ getDockerContainerLatestCheck,
+ getDockerContainerStats,
+ getDockerLatestCheck,
+ getDockerStats,
+ getDockerTotalChecks,
+ getDockerUpChecks,
+} from "@/domain/checks/check.docker.aggregation.js";
const SERVICE_NAME = "ChecksRepository";
@@ -167,6 +176,8 @@ class MongoChecksRepository implements IChecksRepository {
seo: doc.seo,
performance: doc.performance,
audits: mapAudits(doc.audits),
+ containers: doc.containers,
+ containerSummary: doc.containerSummary,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -302,9 +313,36 @@ class MongoChecksRepository implements IChecksRepository {
if (options?.type === "pagespeed") {
return this.findPageSpeedDateRangeChecks(monitorObjectId, start, end, dateString);
}
+ if (options?.type === "docker") {
+ return this.findDockerDateRangeChecks(monitorObjectId, start, end, dateString);
+ }
return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString);
};
+ findDockerContainerChecks = async (
+ monitorId: string,
+ containerName: string,
+ dateRange: DateRange
+ ): Promise> => {
+ const dates = { start: getDateForRange(dateRange), end: new Date() };
+ const dateString = getDateFormat(dateRange);
+ const [aggregate, latestDoc] = await Promise.all([
+ getDockerContainerStats(monitorId, containerName, dates, dateString),
+ getDockerContainerLatestCheck(monitorId, containerName),
+ ]);
+ const latestContainer = latestDoc?.containers?.find((c) => c.name === containerName);
+ return {
+ aggregate,
+ latest:
+ latestDoc && latestContainer
+ ? {
+ container: latestContainer,
+ checkedAt: toDateString(latestDoc.createdAt),
+ }
+ : null,
+ };
+ };
+
findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
@@ -412,7 +450,7 @@ class MongoChecksRepository implements IChecksRepository {
return totalDeleted;
};
private findUptimeDateRangeChecks = async (
- monitorType: Exclude,
+ monitorType: Exclude,
monitorObjectId: mongoose.Types.ObjectId,
startDate: Date,
endDate: Date,
@@ -628,6 +666,30 @@ class MongoChecksRepository implements IChecksRepository {
groupedChecks: result?.groupedChecks ?? [],
};
};
+
+ private findDockerDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => {
+ const monitorId = monitorObjectId.toHexString();
+ const dates = { start: startDate, end: endDate };
+ const [totalChecks, upChecks, aggregate, latestDoc] = await Promise.all([
+ getDockerTotalChecks(monitorId, dates),
+ getDockerUpChecks(monitorId, dates),
+ getDockerStats(monitorId, dates, dateString),
+ getDockerLatestCheck(monitorId),
+ ]);
+ return {
+ monitorType: "docker" as const,
+ aggregateData: { totalChecks: totalChecks ?? 0 },
+ upChecks,
+ aggregate,
+ latest: latestDoc
+ ? {
+ containers: latestDoc.containers ?? [],
+ summary: latestDoc.containerSummary,
+ checkedAt: toDateString(latestDoc.createdAt),
+ }
+ : null,
+ };
+ };
}
export default MongoChecksRepository;
diff --git a/server/src/domain/checks/check.service.ts b/server/src/domain/checks/check.service.ts
index 40dd88f80..5eb071bab 100644
--- a/server/src/domain/checks/check.service.ts
+++ b/server/src/domain/checks/check.service.ts
@@ -2,7 +2,7 @@ import { Types } from "mongoose";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import type { Check, CheckErrorInfo, ChecksQueryResult, ChecksSummary, ILighthouseAudit } from "@/domain/checks/check.type.js";
-import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
+import type { DockerStatusPayload, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
@@ -131,6 +131,12 @@ export class CheckService implements ICheckService {
check.capture = hardwarePayload?.capture;
check.net = net;
}
+
+ if (type === "docker") {
+ const dockerPayload = payload as DockerStatusPayload | undefined;
+ check.containers = dockerPayload?.containers;
+ check.containerSummary = dockerPayload?.summary;
+ }
return check;
};
diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts
index 3ee208d04..65ef4f60a 100644
--- a/server/src/domain/checks/check.snapshot.ts
+++ b/server/src/domain/checks/check.snapshot.ts
@@ -11,6 +11,7 @@ import type {
SnapshotHostInfo,
SnapshotMemoryInfo,
} from "@/domain/checks/check.type.js";
+import { DockerContainerSummary } from "@/domain/docker/docker.type.js";
import { toDateString } from "@/utils/mongoMappers.js";
export type CheckSnapshotSource = Pick<
@@ -23,6 +24,7 @@ export type CheckSnapshotSource = Pick<
disk?: CheckDiskInfo[];
host?: CheckHostInfo;
audits?: CheckAudits;
+ containerSummary?: DockerContainerSummary;
};
const mapCpu = (cpu?: CheckCpuInfo): SnapshotCpuInfo | undefined =>
@@ -66,6 +68,9 @@ const mapAudits = (audits?: CheckAudits): CheckAudits | undefined =>
tbt: audits.tbt,
};
+const mapContainerSummary = (summary?: DockerContainerSummary): DockerContainerSummary | undefined =>
+ summary && { total: summary.total, running: summary.running, stopped: summary.stopped, unhealthy: summary.unhealthy };
+
export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ({
id: source.id,
status: source.status,
@@ -82,4 +87,5 @@ export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => (
seo: source.seo,
performance: source.performance,
audits: mapAudits(source.audits),
+ containerSummary: mapContainerSummary(source.containerSummary),
});
diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts
index aa1eb3782..afea301be 100644
--- a/server/src/domain/checks/check.type.ts
+++ b/server/src/domain/checks/check.type.ts
@@ -1,4 +1,5 @@
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
+import { DockerContainerInfo, DockerContainerSummary } from "@/domain/docker/docker.type.js";
import type { Response } from "got";
export const CHECK_TTL_SENTINEL = 366;
@@ -107,6 +108,8 @@ export interface Check {
host?: CheckHostInfo;
errors?: CheckErrorInfo[];
capture?: CheckCaptureInfo;
+ containers?: DockerContainerInfo[];
+ containerSummary?: DockerContainerSummary;
net?: CheckNetworkInterfaceInfo[];
accessibility?: number;
bestPractices?: number;
@@ -147,11 +150,10 @@ export interface PageSpeedGroupedCheck {
accessibility: number;
bestPractices: number;
seo: number;
- totalChecks: number;
}
export interface UptimeChecksResult {
- monitorType: Exclude;
+ monitorType: Exclude;
groupedChecks: GroupedUptimeCheck[];
groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[];
@@ -197,6 +199,8 @@ export type CheckSnapshot = Pick<
memory?: SnapshotMemoryInfo;
disk?: SnapshotDiskInfo[];
host?: SnapshotHostInfo;
+ // Docker Only
+ containerSummary?: DockerContainerSummary;
};
export interface HardwareDiskStats {
name: string;
@@ -252,3 +256,46 @@ export type DailyCheckBucket = {
downChecks: number;
avgResponseTime: number | null;
};
+
+export type DockerStatsBucket = {
+ _id: string;
+ avgResponseTime: number | null;
+ upCount: number;
+ totalCount: number;
+ avgRunning: number | null;
+ avgTotal: number | null;
+ avgUnhealthy: number | null;
+};
+
+export interface DockerStats {
+ aggregateData: { totalChecks: number };
+ upChecks: { totalChecks: number };
+ aggregate: DockerStatsBucket[];
+ latest: {
+ containers: DockerContainerInfo[];
+ summary?: DockerContainerSummary;
+ checkedAt: string;
+ } | null;
+}
+
+export interface DockerChecksResult extends DockerStats {
+ monitorType: "docker";
+}
+
+export type DockerContainerStatsBucket = {
+ _id: string;
+ avgCpuPct: number | null;
+ avgMemoryUsedBytes: number | null;
+ avgMemoryPct: number | null;
+ minRestartCount: number | null;
+ maxRestartCount: number | null;
+};
+
+export interface DockerContainerStats {
+ aggregate: DockerContainerStatsBucket[];
+ restartsInRange: number;
+ latest: {
+ container: DockerContainerInfo;
+ checkedAt: string;
+ } | null;
+}
diff --git a/server/src/domain/docker/docker-log.model.ts b/server/src/domain/docker/docker-log.model.ts
new file mode 100644
index 000000000..269733ecd
--- /dev/null
+++ b/server/src/domain/docker/docker-log.model.ts
@@ -0,0 +1,60 @@
+import { Schema, model, Types } from "mongoose";
+import { DockerLogStreams, type DockerLogLine } from "@/domain/docker/docker.type.js";
+import type { DockerLog, DockerLogMetadata } from "@/domain/docker/docker-log.type.js";
+
+type DockerLogMetadataDocument = Omit & {
+ monitorId: Types.ObjectId;
+ teamId: Types.ObjectId;
+};
+
+type DockerLogDocumentBase = Omit & {
+ metadata: DockerLogMetadataDocument;
+ checkedAt: Date;
+ expiry: Date;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
+export interface DockerLogDocument extends DockerLogDocumentBase {
+ _id: Types.ObjectId;
+}
+
+const dockerLogMetadataSchema = new Schema(
+ {
+ monitorId: { type: Schema.Types.ObjectId, required: true },
+ teamId: { type: Schema.Types.ObjectId, required: true },
+ containerId: { type: String, required: true },
+ containerName: { type: String, required: true },
+ },
+ { _id: false }
+);
+
+const dockerLogLineSchema = new Schema(
+ {
+ ts: { type: String, required: true },
+ stream: { type: String, enum: DockerLogStreams, required: true },
+ text: { type: String, default: "" },
+ },
+ { _id: false }
+);
+
+const DockerLogSchema = new Schema(
+ {
+ metadata: { type: dockerLogMetadataSchema, required: true },
+ lines: { type: [dockerLogLineSchema], required: true, default: [] },
+ gap: { type: Boolean, default: false },
+ checkedAt: { type: Date, required: true },
+ expiry: { type: Date, required: true, index: { expires: 0 } }, // TTL: Mongo deletes the doc once `expiry` passes
+ },
+ { timestamps: true }
+);
+
+DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerName": 1, checkedAt: -1 }); // API paging
+DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerId": 1, checkedAt: -1 }); // cursor seed
+DockerLogSchema.index({ "metadata.teamId": 1 });
+
+const DockerLogModel = model("DockerLog", DockerLogSchema);
+
+export type { DockerLogMetadataDocument };
+export { DockerLogModel };
+export default DockerLogModel;
diff --git a/server/src/domain/docker/docker-log.repository.interface.ts b/server/src/domain/docker/docker-log.repository.interface.ts
new file mode 100644
index 000000000..10f0ce8aa
--- /dev/null
+++ b/server/src/domain/docker/docker-log.repository.interface.ts
@@ -0,0 +1,10 @@
+import { DockerLog } from "@/domain/docker/docker-log.type.js";
+
+export interface IDockerLogsRepository {
+ createDockerLogs(logs: Omit[]): Promise;
+ findLastLineTimestamp(monitorId: string, containerId: string): Promise;
+ findByContainerName(args: { monitorId: string; containerName: string; before?: Date; after?: Date; limit: number }): Promise;
+ deleteByMonitorId(monitorId: string): Promise;
+ deleteByTeamId(teamId: string): Promise;
+ deleteByMonitorIdsNotIn(monitorIds: string[]): Promise;
+}
diff --git a/server/src/domain/docker/docker-log.repository.mongo.ts b/server/src/domain/docker/docker-log.repository.mongo.ts
new file mode 100644
index 000000000..8ee90e535
--- /dev/null
+++ b/server/src/domain/docker/docker-log.repository.mongo.ts
@@ -0,0 +1,99 @@
+import DockerLogModel, { DockerLogDocument } from "@/domain/docker/docker-log.model.js";
+import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
+import { DockerLog } from "@/domain/docker/docker-log.type.js";
+import { toDateString, toStringId } from "@/utils/mongoMappers.js";
+import mongoose from "mongoose";
+
+const SERVICE_NAME = "DockerLogsRepository";
+
+class MongoDockerLogsRepository implements IDockerLogsRepository {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private toEntity = (doc: DockerLogDocument): DockerLog => {
+ return {
+ id: toStringId(doc._id),
+ metadata: {
+ monitorId: toStringId(doc.metadata.monitorId),
+ teamId: toStringId(doc.metadata.teamId),
+ containerId: doc.metadata.containerId,
+ containerName: doc.metadata.containerName,
+ },
+ lines: (doc.lines ?? []).map((line) => ({ ts: line.ts, stream: line.stream, text: line.text ?? "" })),
+ gap: doc.gap ?? false,
+ checkedAt: toDateString(doc.checkedAt),
+ expiry: toDateString(doc.expiry),
+ createdAt: toDateString(doc.createdAt),
+ updatedAt: toDateString(doc.updatedAt),
+ };
+ };
+
+ createDockerLogs = async (logs: Omit[]): Promise => {
+ if (logs.length === 0) return 0;
+ const docs = await DockerLogModel.insertMany(
+ logs.map((log) => {
+ return {
+ metadata: {
+ monitorId: new mongoose.Types.ObjectId(log.metadata.monitorId),
+ teamId: new mongoose.Types.ObjectId(log.metadata.teamId),
+ containerId: log.metadata.containerId,
+ containerName: log.metadata.containerName,
+ },
+ lines: log.lines,
+ gap: log.gap,
+ checkedAt: new Date(log.checkedAt),
+ expiry: new Date(log.expiry),
+ };
+ }),
+ { ordered: false }
+ );
+ return docs.length;
+ };
+
+ findLastLineTimestamp = async (monitorId: string, containerId: string): Promise => {
+ const doc = await DockerLogModel.findOne({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.containerId": containerId,
+ })
+ .sort({ checkedAt: -1 })
+ .select({ lines: { $slice: -1 } })
+ .lean>();
+ return doc?.lines?.[0]?.ts ?? null;
+ };
+
+ findByContainerName = async ({
+ monitorId,
+ containerName,
+ before,
+ after,
+ limit,
+ }: {
+ monitorId: string;
+ containerName: string;
+ before?: Date;
+ after?: Date;
+ limit: number;
+ }): Promise => {
+ const docs = await DockerLogModel.find({
+ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
+ "metadata.containerName": containerName,
+ ...(before ? { checkedAt: { $lt: before } } : {}),
+ ...(after ? { checkedAt: { $gt: after } } : {}),
+ })
+ .sort({ checkedAt: after ? 1 : -1 })
+ .limit(limit)
+ .lean();
+ const entities = docs.map(this.toEntity);
+ return after ? entities.reverse() : entities;
+ };
+
+ deleteByMonitorId = async (monitorId: string) =>
+ (await DockerLogModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) })).deletedCount ?? 0;
+
+ deleteByTeamId = async (teamId: string) =>
+ (await DockerLogModel.deleteMany({ "metadata.teamId": new mongoose.Types.ObjectId(teamId) })).deletedCount ?? 0;
+
+ deleteByMonitorIdsNotIn = async (monitorIds: string[]) =>
+ (await DockerLogModel.deleteMany({ "metadata.monitorId": { $nin: monitorIds.map((id) => new mongoose.Types.ObjectId(id)) } })).deletedCount ?? 0;
+}
+
+export default MongoDockerLogsRepository;
diff --git a/server/src/domain/docker/docker-log.service.ts b/server/src/domain/docker/docker-log.service.ts
new file mode 100644
index 000000000..646666bab
--- /dev/null
+++ b/server/src/domain/docker/docker-log.service.ts
@@ -0,0 +1,100 @@
+import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
+import { DOCKER_LOG_RETENTION_DAYS, DockerLog } from "@/domain/docker/docker-log.type.js";
+import { DOCKER_LOG_TAIL_LINES, DockerLogLine } from "@/domain/docker/docker.type.js";
+import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
+import { ILogger } from "@/utils/logger.js";
+import mongoose from "mongoose";
+
+const SERVICE_NAME = "DockerLogsService";
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+export interface IDockerLogsService {
+ buildDockerLogs(status: MonitorStatusResponse): Promise;
+ createDockerLogs(logs: DockerLog[]): Promise;
+}
+
+export class DockerLogsService implements IDockerLogsService {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private logger: ILogger;
+ private dockerLogsRepository: IDockerLogsRepository;
+
+ private lastTimestampByContainer = new Map();
+
+ constructor({ logger, dockerLogsRepository }: { logger: ILogger; dockerLogsRepository: IDockerLogsRepository }) {
+ this.logger = logger;
+ this.dockerLogsRepository = dockerLogsRepository;
+ }
+
+ private generateCursorKey = (monitorId: string, containerId: string) => `${monitorId}:${containerId}`;
+
+ private getLastTimestamp = async (monitorId: string, containerId: string) => {
+ const key = this.generateCursorKey(monitorId, containerId);
+ const cached = this.lastTimestampByContainer.get(key);
+ if (cached !== undefined) return cached || null;
+ const stored = await this.dockerLogsRepository.findLastLineTimestamp(monitorId, containerId);
+ this.lastTimestampByContainer.set(key, stored ?? "");
+ return stored;
+ };
+
+ private newLines = (lines: DockerLogLine[], lastTimestamp: string | null): DockerLogLine[] => {
+ return lastTimestamp
+ ? lines.filter((line) => {
+ return line.ts > lastTimestamp;
+ })
+ : lines;
+ };
+
+ private maxTimestamp = (lines: DockerLogLine[]) => {
+ return lines.reduce((max, line) => (line.ts > max ? line.ts : max), "");
+ };
+
+ buildDockerLogs = async (status: MonitorStatusResponse): Promise => {
+ const payload = typeof status.payload === "object" ? status.payload : null;
+ const containerLogs = payload?.logs ?? [];
+ if (containerLogs.length === 0) return [];
+
+ const now = new Date();
+ const checkedAt = now.toISOString();
+ const expiry = new Date(now.getTime() + DOCKER_LOG_RETENTION_DAYS * MS_PER_DAY).toISOString();
+ const results: DockerLog[] = [];
+
+ for (const dockerContainerLog of containerLogs) {
+ const { containerId, containerName, lines } = dockerContainerLog;
+ try {
+ const lastTimestamp = await this.getLastTimestamp(status.monitorId, containerId);
+ const fresh = this.newLines(lines, lastTimestamp);
+ if (fresh.length === 0) continue;
+ this.lastTimestampByContainer.set(this.generateCursorKey(status.monitorId, containerId), this.maxTimestamp(fresh));
+ results.push({
+ id: new mongoose.Types.ObjectId().toString(),
+ metadata: {
+ monitorId: status.monitorId,
+ teamId: status.teamId,
+ containerId,
+ containerName,
+ },
+ lines: fresh,
+ gap: fresh.length === DOCKER_LOG_TAIL_LINES,
+ // We only fetch DOCKER_LOG_TAIL_LINES worth of logs.
+ // If we have 200 fresh logs, there _could_ be more than 200 lines, so set gap = true
+ // Client can display that some logs may be missing
+ checkedAt,
+ expiry,
+ createdAt: checkedAt,
+ updatedAt: checkedAt,
+ });
+ } catch (error: unknown) {
+ this.logger.warn({
+ message: `Failed to build docker logs for container ${containerName}`,
+ service: SERVICE_NAME,
+ method: "buildDockerLogs",
+ details: { monitorId: status.monitorId, containerId, error: error instanceof Error ? error.message : String(error) },
+ });
+ }
+ }
+ return results;
+ };
+
+ createDockerLogs = async (logs: DockerLog[]): Promise => this.dockerLogsRepository.createDockerLogs(logs);
+}
diff --git a/server/src/domain/docker/docker-log.type.ts b/server/src/domain/docker/docker-log.type.ts
new file mode 100644
index 000000000..28448f41c
--- /dev/null
+++ b/server/src/domain/docker/docker-log.type.ts
@@ -0,0 +1,28 @@
+import { DockerLogLine } from "@/domain/docker/docker.type.js";
+
+export const DOCKER_LOG_RETENTION_DAYS = 7;
+export const DOCKER_LOG_PAGE_DEFAULT = 20;
+export const DOCKER_LOG_PAGE_MAX = 50;
+
+export interface DockerLogMetadata {
+ monitorId: string;
+ teamId: string;
+ containerId: string;
+ containerName: string;
+}
+
+export interface DockerLog {
+ id: string;
+ metadata: DockerLogMetadata;
+ lines: DockerLogLine[];
+ gap: boolean;
+ checkedAt: string;
+ expiry: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DockerLogPage {
+ logs: DockerLog[];
+ nextCursor: string | null;
+}
diff --git a/server/src/domain/docker/docker.type.ts b/server/src/domain/docker/docker.type.ts
new file mode 100644
index 000000000..66504e273
--- /dev/null
+++ b/server/src/domain/docker/docker.type.ts
@@ -0,0 +1,65 @@
+export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const;
+export type DockerContainerState = (typeof DockerContainerStates)[number];
+
+export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const;
+export type DockerHealthStatus = (typeof DockerHealthStatuses)[number];
+
+export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const;
+export type DockerPortProtocol = (typeof DockerPortProtocols)[number];
+
+export const DockerLogStreams = ["stdout", "stderr"] as const;
+export type DockerLogStream = (typeof DockerLogStreams)[number];
+
+export const DOCKER_LOG_TAIL_LINES = 200;
+
+export interface DockerLogLine {
+ ts: string;
+ stream: DockerLogStream;
+ text: string;
+}
+
+export interface DockerContainerLogs {
+ containerId: string;
+ containerName: string;
+ lines: DockerLogLine[];
+}
+
+export interface DockerContainerPort {
+ privatePort: number;
+ protocol: DockerPortProtocol;
+ publicPort?: number;
+ hostIp?: string;
+}
+
+export interface DockerContainerMount {
+ type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, …
+ name?: string;
+ source: string;
+ destination: string;
+ mode: string;
+ rw: boolean;
+}
+
+export interface DockerContainerInfo {
+ id: string;
+ name: string;
+ image: string;
+ state: DockerContainerState;
+ status: string;
+ health: DockerHealthStatus;
+ cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores
+ memoryUsedBytes?: number;
+ memoryLimitBytes?: number;
+ memoryPct?: number; // 0-1 fraction of the memory limit
+ restartCount?: number;
+ startedAt?: string; // ISO date
+ ports?: DockerContainerPort[];
+ mounts?: DockerContainerMount[];
+}
+
+export interface DockerContainerSummary {
+ total: number;
+ running: number;
+ stopped: number;
+ unhealthy: number;
+}
diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts
index 15b277efb..c04107651 100644
--- a/server/src/domain/monitors/monitor.model.ts
+++ b/server/src/domain/monitors/monitor.model.ts
@@ -9,6 +9,7 @@ import type {
SnapshotHostInfo,
SnapshotMemoryInfo,
} from "@/domain/checks/check.type.js";
+import { containerSummarySchema } from "@/domain/checks/check.model.js";
type CheckSnapshotDocument = Omit & { createdAt: Date };
@@ -113,6 +114,7 @@ const checkSnapshotSchema = new Schema(
seo: { type: Number },
performance: { type: Number },
audits: { type: snapshotAuditsSchema },
+ containerSummary: { type: containerSummarySchema },
createdAt: { type: Date, required: true },
},
{ _id: false, suppressReservedKeysWarning: true }
@@ -301,6 +303,24 @@ const MonitorSchema = new Schema(
type: Number,
default: 300000,
},
+ dockerLogsEnabled: {
+ type: Boolean,
+ default: false,
+ },
+ dockerTlsCa: {
+ type: String,
+ },
+ dockerTlsCert: {
+ type: String,
+ },
+ dockerTlsKey: {
+ type: String,
+ select: false,
+ },
+ dockerTlsKeySet: {
+ type: Boolean,
+ default: false,
+ },
dnsServer: {
type: String,
},
diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts
index 1b12fef71..5de43aa2c 100644
--- a/server/src/domain/monitors/monitor.repository.interface.ts
+++ b/server/src/domain/monitors/monitor.repository.interface.ts
@@ -55,6 +55,11 @@ export interface IMonitorsRepository {
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise;
findMonitorCountByProxyId(proxyId: string): Promise;
+ // Docker TLS
+ findDockerTlsKeyById(monitorId: string): Promise;
+ findAllDockerTlsKeys(): Promise<{ id: string; dockerTlsKey: string }[]>;
+ updateDockerTlsKey(monitorId: string, dockerTlsKey: string): Promise; // Needed for worker to rotate keys, no teamId/userId
+
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise;
removeNotificationFromMonitors(notificationId: string): Promise;
diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts
index 8f773f983..385dcebab 100644
--- a/server/src/domain/monitors/monitor.repository.mongo.ts
+++ b/server/src/domain/monitors/monitor.repository.mongo.ts
@@ -298,6 +298,20 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return { monitors: this.mapDocuments(monitors), deletedCount };
};
+ findDockerTlsKeyById = async (monitorId: string): Promise => {
+ const doc = await MonitorModel.findOne({ _id: monitorId }, { dockerTlsKey: 1 }).lean();
+ return doc?.dockerTlsKey ?? null;
+ };
+
+ findAllDockerTlsKeys = async (): Promise<{ id: string; dockerTlsKey: string }[]> => {
+ const docs = await MonitorModel.find({ dockerTlsKeySet: true }, { dockerTlsKey: 1 }).lean();
+ return docs.flatMap((doc) => (doc.dockerTlsKey ? [{ id: doc._id.toString(), dockerTlsKey: doc.dockerTlsKey }] : []));
+ };
+
+ updateDockerTlsKey = async (monitorId: string, dockerTlsKey: string): Promise => {
+ await MonitorModel.updateOne({ _id: monitorId }, { $set: { dockerTlsKey } });
+ };
+
findMonitorsSummaryByTeamId = async (teamId: string, config: SummaryConfig): Promise => {
const match = this.queryBuilder(config, teamId);
const pipeline = [
@@ -453,6 +467,10 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
+ dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
+ dockerTlsCa: doc.dockerTlsCa ?? undefined,
+ dockerTlsCert: doc.dockerTlsCert ?? undefined,
+ dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts
index 6cf9af54b..5d02098ba 100644
--- a/server/src/domain/monitors/monitor.service.ts
+++ b/server/src/domain/monitors/monitor.service.ts
@@ -7,9 +7,18 @@ import type {
PageSpeedDetailsResult,
GamesMap,
GroupedGeoCheckResult,
+ DockerDetailsResult,
+ DockerContainerDetailsResult,
+ DockerContainerLogsResult,
} from "@/domain/monitors/monitor.type.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js";
-import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js";
+import type {
+ UptimeChecksResult,
+ HardwareChecksResult,
+ PageSpeedChecksResult,
+ DockerChecksResult,
+ DockerContainerStatsBucket,
+} from "@/domain/checks/check.type.js";
import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js";
@@ -23,15 +32,37 @@ import type { ImportedMonitor } from "@/api/validation/monitorValidation.js";
import { ILogger } from "@/utils/logger.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
+import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
+import { IEncryptionService } from "@/service/encryption/encryptionService.js";
+import { isDockerTlsUrl } from "@/utils/dockerHost.js";
const SERVICE_NAME = "MonitorService";
-const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
- supportsUptimeDetails(result.monitorType);
+const isUptimeChecksResult = (
+ result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult
+): result is UptimeChecksResult => supportsUptimeDetails(result.monitorType);
+
+// Docker's RestartCount is cumulative and resets to 0 when a container is recreated, so restarts in the
+// date range are recovered from bucket min/max deltas; a drop between buckets means a recreate.
+const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number => {
+ let restarts = 0;
+ let prevMax: number | null = null;
+ for (const bucket of aggregate) {
+ const { minRestartCount: min, maxRestartCount: max } = bucket;
+ if (min === null || max === null) continue;
+ restarts += Math.max(0, max - min);
+ if (prevMax !== null) {
+ const delta = min - prevMax;
+ restarts += delta >= 0 ? delta : min + 1;
+ }
+ prevMax = max;
+ }
+ return restarts;
+};
export interface IMonitorService {
// create
- createMonitor(teamId: string, userId: string, body: Partial): Promise;
+ createMonitor(teamId: string, userId: string, body: Partial): Promise;
createMonitors(monitors: Array): Promise;
addDemoMonitors(args: { userId: string; teamId: string }): Promise;
@@ -39,6 +70,21 @@ export interface IMonitorService {
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise;
+ getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise;
+ getDockerContainerByName(args: {
+ teamId: string;
+ monitorId: string;
+ containerName: string;
+ dateRange: DateRange;
+ }): Promise;
+ getDockerContainerLogs(args: {
+ teamId: string;
+ monitorId: string;
+ containerName: string;
+ before?: Date;
+ after?: Date;
+ limit: number;
+ }): Promise;
getGeoChecksByMonitorId(args: {
teamId: string;
monitorId: string;
@@ -96,9 +142,11 @@ export class MonitorService implements IMonitorService {
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private geoChecksRepository: IGeoChecksRepository;
+ private dockerLogsRepository: IDockerLogsRepository;
private monitorStatsRepository: IMonitorStatsRepository;
private statusPagesRepository: IStatusPagesRepository;
private incidentsRepository: IIncidentsRepository;
+ private encryptionService: IEncryptionService;
constructor({
scheduler,
@@ -107,9 +155,11 @@ export class MonitorService implements IMonitorService {
monitorsRepository,
checksRepository,
geoChecksRepository,
+ dockerLogsRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
+ encryptionService,
}: {
scheduler: IJobScheduler;
logger: ILogger;
@@ -117,9 +167,11 @@ export class MonitorService implements IMonitorService {
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
+ dockerLogsRepository: IDockerLogsRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
incidentsRepository: IIncidentsRepository;
+ encryptionService: IEncryptionService;
}) {
this.scheduler = scheduler;
this.logger = logger;
@@ -127,22 +179,29 @@ export class MonitorService implements IMonitorService {
this.monitorsRepository = monitorsRepository;
this.checksRepository = checksRepository;
this.geoChecksRepository = geoChecksRepository;
+ this.dockerLogsRepository = dockerLogsRepository;
this.monitorStatsRepository = monitorStatsRepository;
this.statusPagesRepository = statusPagesRepository;
this.incidentsRepository = incidentsRepository;
+ this.encryptionService = encryptionService;
}
- createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => {
+ createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => {
// proxyId is only needed in custom mode
if (body.proxyMode !== "custom") {
delete body.proxyId;
}
+
+ // Encrypt docker TLS keys
+ this.encryptDockerTls(body, "create");
+
const monitor = await this.monitorsRepository.create(body, teamId, userId);
if (!monitor) {
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
}
this.scheduler.addJob(monitor.id, monitor);
+ return monitor;
};
createMonitors = async (monitors: Array): Promise => {
@@ -281,6 +340,110 @@ export class MonitorService implements IMonitorService {
monitorStats,
};
};
+ getDockerDetailsById = async ({
+ teamId,
+ monitorId,
+ dateRange,
+ }: {
+ teamId: string;
+ monitorId: string;
+ dateRange: DateRange;
+ }): Promise => {
+ const monitor = await this.monitorsRepository.findById(monitorId, teamId);
+ if (!monitor) {
+ throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
+ }
+ if (monitor.type !== "docker") {
+ throw new AppError({ message: `${monitor.type} monitors are not supported for docker details`, status: 400 });
+ }
+
+ const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, {
+ type: monitor.type,
+ });
+
+ if (checksData.monitorType !== "docker") {
+ throw new AppError({ message: "Unable to load docker stats for this monitor", status: 500 });
+ }
+
+ const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
+
+ return {
+ monitor,
+ stats: {
+ aggregateData: checksData.aggregateData,
+ upChecks: checksData.upChecks,
+ aggregate: checksData.aggregate,
+ latest: checksData.latest,
+ },
+ monitorStats,
+ };
+ };
+
+ getDockerContainerByName = async ({
+ teamId,
+ monitorId,
+ containerName,
+ dateRange,
+ }: {
+ teamId: string;
+ monitorId: string;
+ containerName: string;
+ dateRange: DateRange;
+ }): Promise => {
+ const monitor = await this.monitorsRepository.findById(monitorId, teamId);
+ if (!monitor) {
+ throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
+ }
+ if (monitor.type !== "docker") {
+ throw new AppError({ message: `${monitor.type} monitors are not supported for docker container details`, status: 400 });
+ }
+
+ const { aggregate, latest } = await this.checksRepository.findDockerContainerChecks(monitor.id, containerName, dateRange);
+
+ return {
+ monitor,
+ stats: {
+ aggregate,
+ latest,
+ restartsInRange: computeRestartsInRange(aggregate),
+ },
+ };
+ };
+
+ getDockerContainerLogs = async ({
+ teamId,
+ monitorId,
+ containerName,
+ before,
+ after,
+ limit,
+ }: {
+ teamId: string;
+ monitorId: string;
+ containerName: string;
+ before?: Date;
+ after?: Date;
+ limit: number;
+ }): Promise => {
+ const monitor = await this.monitorsRepository.findById(monitorId, teamId);
+ if (!monitor) {
+ throw new AppError({
+ message: `Monitor with ID ${monitorId} not found`,
+ status: 404,
+ });
+ }
+
+ if (monitor.type !== "docker") {
+ throw new AppError({
+ message: `${monitor.type} monitors are not supported for docker container logs`,
+ status: 400,
+ });
+ }
+
+ const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, after, limit });
+ const nextCursor = !after && logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null;
+ return { logs, nextCursor };
+ };
getGeoChecksByMonitorId = async ({
teamId,
@@ -384,6 +547,20 @@ export class MonitorService implements IMonitorService {
if (unsetProxyId) {
delete body.proxyId;
}
+
+ // Handle Docker TLS
+ this.encryptDockerTls(body, "edit");
+ if (body.type === "docker" && isDockerTlsUrl(body.url) && body.dockerTlsKey === undefined) {
+ const stored = await this.monitorsRepository.findById(monitorId, teamId);
+ if (!stored.dockerTlsKeySet) {
+ throw new AppError({
+ message: "Key is required for a TLS Docker host",
+ status: 422,
+ service: SERVICE_NAME,
+ method: "editMonitor",
+ });
+ }
+ }
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
@@ -490,6 +667,14 @@ export class MonitorService implements IMonitorService {
});
});
+ await this.dockerLogsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
+ this.logger.warn({
+ message: `Error deleting docker logs for monitor ${monitor.id} with name ${monitor.name}`,
+ service: SERVICE_NAME,
+ stack: err instanceof Error ? err.stack : undefined,
+ });
+ });
+
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
@@ -563,4 +748,34 @@ export class MonitorService implements IMonitorService {
return { imported: createdMonitors!.length, errors };
};
+
+ private encryptDockerTls = (body: Partial, mode: "create" | "edit") => {
+ if (body.type !== "docker" && mode === "create") return;
+
+ if (body.url !== undefined && !isDockerTlsUrl(body.url)) {
+ body.dockerTlsCa = undefined;
+ body.dockerTlsCert = undefined;
+ body.dockerTlsKey = undefined;
+ body.dockerTlsKeySet = false;
+ return;
+ }
+
+ if (!body.dockerTlsKey) {
+ delete body.dockerTlsKey; // empty field means keeps stored key
+ return;
+ }
+
+ if (!this.encryptionService.isConfigured()) {
+ throw new AppError({
+ message:
+ 'Docker TLS credentials require ENCRYPTION_KEY to be set on the server. Generate one with "openssl rand -base64 32", set the same value for both the API and worker, and restart.',
+ status: 422,
+ service: SERVICE_NAME,
+ method: "encryptDockerTls",
+ });
+ }
+
+ body.dockerTlsKey = this.encryptionService.encrypt(body.dockerTlsKey);
+ body.dockerTlsKeySet = true;
+ };
}
diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts
index 59d91ced9..df8c52d9e 100644
--- a/server/src/domain/monitors/monitor.type.ts
+++ b/server/src/domain/monitors/monitor.type.ts
@@ -1,9 +1,11 @@
-import type { CheckSnapshot } from "@/domain/checks/check.type.js";
+import type { CheckSnapshot, DockerContainerStats, DockerStats } from "@/domain/checks/check.type.js";
export type { CheckSnapshot } from "@/domain/checks/check.type.js";
import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js";
export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js";
import http from "node:http";
import { HardwareStats } from "@/domain/checks/check.type.js";
+import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js";
+import { DockerLogPage } from "@/domain/docker/docker-log.type.js";
export const HttpStatusCodes = [
...Object.keys(http.STATUS_CODES).map(Number),
@@ -44,16 +46,7 @@ export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupporte
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
-export const UptimeDetailsSupportedTypes = [
- "http",
- "ping",
- "docker",
- "port",
- "game",
- "grpc",
- "websocket",
- "dns",
-] as const satisfies readonly MonitorType[];
+export const UptimeDetailsSupportedTypes = ["http", "ping", "port", "game", "grpc", "websocket", "dns"] as const satisfies readonly MonitorType[];
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
@@ -115,6 +108,12 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
+ dockerLogsEnabled?: boolean;
+ dockerTlsCa?: string;
+ dockerTlsCert?: string;
+ // EncryptionService ciphertext. Normally excluded
+ dockerTlsKey?: string;
+ dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -161,6 +160,17 @@ export interface HardwareDetailsResult {
monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null;
}
+export interface DockerDetailsResult {
+ monitor: Monitor;
+ stats: DockerStats;
+ monitorStats: MonitorStats | null;
+}
+
+export interface DockerContainerDetailsResult {
+ monitor: Monitor;
+ stats: DockerContainerStats;
+}
+
export interface PageSpeedDetailsResult {
monitorData: {
monitor: Monitor;
@@ -185,3 +195,5 @@ export interface Game {
export type GamesMap = Record;
export type MonitorScheduleFields = Pick;
+
+export type DockerContainerLogsResult = DockerLogPage;
diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts
index b2dce9ebe..5be6dc678 100755
--- a/server/src/domain/notifications/notification.model.ts
+++ b/server/src/domain/notifications/notification.model.ts
@@ -37,6 +37,7 @@ const NotificationSchema = new Schema(
"teams",
"telegram",
"pushover",
+ "signalgrid",
"twilio",
"ntfy",
] as NotificationChannel[],
diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts
index a45d4c4f8..628633d56 100644
--- a/server/src/domain/notifications/notification.type.ts
+++ b/server/src/domain/notifications/notification.type.ts
@@ -9,6 +9,7 @@ export const NotificationChannels = [
"teams",
"telegram",
"pushover",
+ "signalgrid",
"twilio",
"ntfy",
] as const;
diff --git a/server/src/domain/notifications/providers/signalgrid.ts b/server/src/domain/notifications/providers/signalgrid.ts
new file mode 100644
index 000000000..fdd930724
--- /dev/null
+++ b/server/src/domain/notifications/providers/signalgrid.ts
@@ -0,0 +1,123 @@
+const SERVICE_NAME = "SignalgridProvider";
+import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
+import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
+import { getTestMessage } from "@/domain/notifications/providers/utils.js";
+import got from "got";
+
+export class SignalgridProvider extends NotificationProvider {
+ async sendTestAlert(notification: Partial): Promise {
+ if (!notification.address || !notification.accessToken) {
+ return false;
+ }
+
+ try {
+ await got.post("https://api.signalgrid.co/v1/push", {
+ form: {
+ client_key: notification.accessToken,
+ channel: notification.address,
+ title: "Checkmate Test Notification",
+ body: getTestMessage(),
+ type: "INFO",
+ critical: false,
+ },
+ ...this.gotRequestOptions(),
+ });
+ return true;
+ } catch (error) {
+ const errMsg = error instanceof Error ? error.message : "unknown error";
+ const errStack = error instanceof Error ? error.stack : undefined;
+ this.logger.warn({
+ message: "Signalgrid test alert failed",
+ service: SERVICE_NAME,
+ method: "sendTestAlert",
+ stack: errStack,
+ details: { error: errMsg },
+ });
+ return false;
+ }
+ }
+
+ async sendMessage(notification: Notification, message: NotificationMessage): Promise {
+ if (!notification.address || !notification.accessToken) {
+ return false;
+ }
+
+ try {
+ await got.post("https://api.signalgrid.co/v1/push", {
+ form: {
+ client_key: notification.accessToken,
+ channel: notification.address,
+ title: message.content.title,
+ body: this.buildSignalgridText(message),
+ type: this.mapSeverity(message.severity),
+ critical: false,
+ },
+ ...this.gotRequestOptions(),
+ });
+
+ this.logger.info({
+ message: "Signalgrid notification sent",
+ service: SERVICE_NAME,
+ method: "sendMessage",
+ });
+ return true;
+ } catch (error) {
+ const errMsg = error instanceof Error ? error.message : "unknown error";
+ const errStack = error instanceof Error ? error.stack : undefined;
+ this.logger.warn({
+ message: "Signalgrid alert failed",
+ service: SERVICE_NAME,
+ method: "sendMessage",
+ stack: errStack,
+ details: { error: errMsg },
+ });
+ return false;
+ }
+ }
+
+ private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" {
+ switch (severity) {
+ case "critical":
+ return "CRIT";
+ case "warning":
+ return "WARN";
+ case "success":
+ return "SUCCESS";
+ default:
+ return "INFO";
+ }
+ }
+
+ private buildSignalgridText(message: NotificationMessage): string {
+ const lines: string[] = [];
+
+ lines.push(message.content.summary);
+ lines.push("");
+ lines.push("Monitor Details:");
+ lines.push(`• Name: ${message.monitor.name}`);
+ lines.push(`• URL: ${message.monitor.url}`);
+ lines.push(`• Type: ${message.monitor.type}`);
+ lines.push(`• Status: ${message.monitor.status}`);
+
+ if (message.content.details && message.content.details.length > 0) {
+ lines.push("");
+ lines.push("Additional Information:");
+ message.content.details.forEach((detail) => lines.push(`• ${detail}`));
+ }
+
+ if (message.content.thresholds && message.content.thresholds.length > 0) {
+ lines.push("");
+ lines.push("Threshold Breaches:");
+ message.content.thresholds.forEach((breach) => {
+ lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
+ });
+ }
+
+ if (message.content.incident) {
+ lines.push("");
+ lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
+ }
+
+ return lines.join("\n");
+ }
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 68ef00a1f..e522fa07e 100755
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -45,7 +45,7 @@ const startApp = async () => {
// ***********************
// Build shared services
// ***********************
- const shared = await buildShared({ logger, envSettings, settingsService });
+ const shared = await buildShared({ logger, envSettings, settingsService, encryptionKeys: env.ENCRYPTION_KEY });
// ***********************
// Worker node path, don't need API
diff --git a/server/src/service/bufferService.ts b/server/src/service/bufferService.ts
index 2d10aebbb..672ec174c 100755
--- a/server/src/service/bufferService.ts
+++ b/server/src/service/bufferService.ts
@@ -5,14 +5,18 @@ import type { ILogger } from "@/utils/logger.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { ICheckService } from "../domain/checks/check.service.js";
+import { DockerLog } from "@/domain/docker/docker-log.type.js";
+import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
+ addDockerLogToBuffer(dockerLog: DockerLog): void;
scheduleNextFlush(): void;
flushBuffer(): Promise;
flushGeoBuffer(): Promise;
+ flushDockerLogsBuffer(): Promise;
shutdown(): Promise;
}
@@ -23,15 +27,18 @@ export class BufferService implements IBufferService {
private SERVICE_NAME: string;
private buffer: Check[];
private geoBuffer: GeoCheck[];
+ private dockerLogBuffer: DockerLog[];
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: ICheckService;
private geoChecksService: IGeoChecksService;
+ private dockerLogsService: IDockerLogsService;
private jobsRepository: IJobsRepository;
constructor(
logger: ILogger,
checkService: ICheckService,
geoChecksService: IGeoChecksService,
+ dockerLogsService: IDockerLogsService,
settingsService: ISettingsService,
jobsRepository: IJobsRepository
) {
@@ -39,10 +46,12 @@ export class BufferService implements IBufferService {
this.logger = logger;
this.checksService = checkService;
this.geoChecksService = geoChecksService;
+ this.dockerLogsService = dockerLogsService;
this.jobsRepository = jobsRepository;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
this.geoBuffer = [];
+ this.dockerLogBuffer = [];
this.scheduleNextFlush();
this.logger.info({
message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`,
@@ -77,6 +86,19 @@ export class BufferService implements IBufferService {
}
}
+ addDockerLogToBuffer(dockerLog: DockerLog): void {
+ try {
+ this.dockerLogBuffer.push(dockerLog);
+ } catch (error: unknown) {
+ this.logger.error({
+ message: error instanceof Error ? error.message : "Unknown error",
+ service: this.SERVICE_NAME,
+ method: "addDockerLogToBuffer",
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ }
+ }
+
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -85,6 +107,7 @@ export class BufferService implements IBufferService {
try {
await this.flushBuffer();
await this.flushGeoBuffer();
+ await this.flushDockerLogsBuffer();
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -150,6 +173,30 @@ export class BufferService implements IBufferService {
}
}
+ async flushDockerLogsBuffer() {
+ if (this.dockerLogBuffer.length === 0) {
+ return;
+ }
+ // Take the batch first so a write that drains it can't be appended to mid-flush
+ const batch = this.dockerLogBuffer;
+ this.dockerLogBuffer = [];
+ try {
+ this.logger.debug({
+ message: `Flushing ${batch.length} docker logs to database`,
+ service: this.SERVICE_NAME,
+ method: "flushDockerLogsBuffer",
+ });
+ await this.dockerLogsService.createDockerLogs(batch);
+ } catch (error: unknown) {
+ this.logger.error({
+ message: error instanceof Error ? error.message : "Unknown error",
+ service: this.SERVICE_NAME,
+ method: "flushDockerLogsBuffer",
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ }
+ }
+
async shutdown() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -157,5 +204,6 @@ export class BufferService implements IBufferService {
}
await this.flushBuffer();
await this.flushGeoBuffer();
+ await this.flushDockerLogsBuffer();
}
}
diff --git a/server/src/service/encryption/encryptionService.ts b/server/src/service/encryption/encryptionService.ts
new file mode 100644
index 000000000..f5b52620d
--- /dev/null
+++ b/server/src/service/encryption/encryptionService.ts
@@ -0,0 +1,176 @@
+import { ILogger } from "@/utils/logger.js";
+import type * as nodeCrypto from "node:crypto";
+import { AppError } from "@/utils/AppError.js";
+
+const SERVICE_NAME = "EncryptionService";
+
+const ENCRYPTION_KEY_BYTES = 32;
+
+// Encryption consts
+const FORMAT_VERSION = "v1";
+const ALGORITHM = "aes-256-gcm";
+const IV_BYTES = 12;
+const TAG_BYTES = 16;
+const KEY_ID_BYTES = 4;
+const SEGMENT_COUNT = 5;
+type V1Segments = [string, string, string, string, string];
+const isV1Segments = (segments: string[]): segments is V1Segments => segments.length === SEGMENT_COUNT;
+const BASE64URL = /^[A-Za-z0-9_-]*$/;
+
+type KeyEntry = { id: string; key: Buffer };
+
+export type EncryptionCryptoLib = Pick;
+
+export interface IEncryptionService {
+ isConfigured(): boolean;
+ currentKeyId(): string;
+ keyIdOf(ciphertext: string): string;
+ needsReencryption(ciphertext: string): boolean;
+ encrypt(plaintext: string): string;
+ decrypt(ciphertext: string): string;
+}
+
+export class EncryptionService implements IEncryptionService {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ private readonly entries: KeyEntry[];
+
+ constructor(
+ keys: Buffer[],
+ private logger: ILogger,
+ private cryptoLib: EncryptionCryptoLib
+ ) {
+ this.entries = keys.map((key, index) => this.toEntry(key, index));
+ this.entries.forEach((entry, index) => {
+ if (this.entries.findIndex((other) => other.id === entry.id) !== index) {
+ throw this.fail("constructor", "ENCRYPTION_KEY key ids collide; regenerate one key", { index });
+ }
+ });
+
+ const current = this.entries[0];
+
+ this.logger.info({
+ message: current
+ ? `Encryption configured with ${this.entries.length} key(s); current key id ${current.id}`
+ : "ENCRYPTION_KEY not set; encrypted fields are unavailable",
+ service: SERVICE_NAME,
+ method: "constructor",
+ });
+ }
+
+ isConfigured = () => {
+ return this.entries.length > 0;
+ };
+
+ currentKeyId = () => {
+ return this.requireCurrentKey("currentKeyId").id;
+ };
+
+ keyIdOf = (ciphertext: string) => {
+ return this.parse(ciphertext, "keyIdOf").keyId;
+ };
+
+ // Check if the ciphertext was encrypted with the current key
+ needsReencryption = (ciphertext: string) => {
+ const currentKey = this.requireCurrentKey("needsReencryption");
+ return this.parse(ciphertext, "needsReencryption").keyId !== currentKey.id;
+ };
+
+ encrypt = (plaintext: string) => {
+ const currentKey = this.requireCurrentKey("encrypt"); // Get the current key for encryption
+ const iv = this.cryptoLib.randomBytes(IV_BYTES); // Get IV_BYTES worth of random bytes for nonce
+ const cipher = this.cryptoLib.createCipheriv(ALGORITHM, currentKey.key, iv, { authTagLength: TAG_BYTES }); // Create the cipher
+ cipher.setAAD(this.aad(currentKey.id)); // set signing key as additional data so it is protected by the verification tag
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); // Encode plaintext as utf8 and run through cipher
+ const tag = cipher.getAuthTag(); // Get verification tag
+ return [FORMAT_VERSION, currentKey.id, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join(".");
+ };
+
+ decrypt = (ciphertext: string) => {
+ this.requireCurrentKey("decrypt"); // Config check
+ const { keyId, iv, tag, data } = this.parse(ciphertext, "decrypt");
+ const key = this.entries.find((entry) => entry.id === keyId)?.key;
+ if (!key) {
+ throw this.fail("decrypt", "No encryption key matches ciphertext", { keyId });
+ }
+ const decipher = this.cryptoLib.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
+ decipher.setAAD(this.aad(keyId)); // reconstruct the additional data, must be done BEFORE auth tag
+ decipher.setAuthTag(tag); // Give the decipher the tag to verify
+ try {
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
+ } catch {
+ throw this.fail("decrypt", "Ciphertext failed authentication");
+ }
+ };
+
+ private fail = (method: string, message: string, details?: Record): AppError => {
+ return new AppError({
+ message,
+ status: 500,
+ service: SERVICE_NAME,
+ method,
+ details,
+ });
+ };
+
+ // Create an ID:Key pair for each key.
+ private toEntry = (key: Buffer, keyIdx: number): KeyEntry => {
+ if (key.length !== ENCRYPTION_KEY_BYTES) throw this.fail("constructor", "ENCRYPTION_KEY is not 32 bytes", { keyIdx });
+
+ const id = this.cryptoLib
+ .createHash("sha256") // Create a sha256 hasher
+ .update(key) // Feed it the key
+ .digest() // Digest a hash
+ .subarray(0, KEY_ID_BYTES) // Keep first KEY_ID_BYTES, enough to avoid collision
+ .toString("base64url");
+ return { id, key };
+ };
+
+ private requireCurrentKey = (method: string): KeyEntry => {
+ const current = this.entries[0];
+ if (!current) {
+ throw this.fail(method, "ENCRYPTION_KEY is not configured");
+ }
+ return current;
+ };
+
+ // v1 Ciphertext stored as a string with five segments:
+ // ....
+ // iv and tag belong to aes-256-gcm standard
+
+ private parse = (ciphertext: string, method: string) => {
+ const segments = ciphertext.split(".");
+ const version = segments[0];
+ if (version !== FORMAT_VERSION) {
+ throw this.fail(method, "Unsupported ciphertext version", { version });
+ }
+
+ // **************************
+ // v1 parser
+ // **************************
+ if (!isV1Segments(segments)) {
+ throw this.fail(method, "Ciphertext is malformed");
+ }
+
+ const [, keyId, ivText, tagText, dataText] = segments; // Skip first segment, we already have version
+
+ if (![keyId, ivText, tagText, dataText].every((segment) => BASE64URL.test(segment))) {
+ throw this.fail(method, "Ciphertext is malformed");
+ }
+
+ const iv = Buffer.from(ivText, "base64url");
+ const tag = Buffer.from(tagText, "base64url");
+
+ if (keyId.length === 0 || iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
+ throw this.fail(method, "Ciphertext is malformed");
+ }
+ return { keyId, iv, tag, data: Buffer.from(dataText, "base64url") };
+ // **************************
+ // End v1 parser
+ // **************************
+ };
+
+ private aad = (keyId: string) => {
+ return Buffer.from(`${FORMAT_VERSION}.${keyId}`, "utf8");
+ };
+}
diff --git a/server/src/service/encryption/secretsRotationService.ts b/server/src/service/encryption/secretsRotationService.ts
new file mode 100644
index 000000000..c4e05881b
--- /dev/null
+++ b/server/src/service/encryption/secretsRotationService.ts
@@ -0,0 +1,60 @@
+import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
+import { IEncryptionService } from "@/service/encryption/encryptionService.js";
+import { AppError } from "@/utils/AppError.js";
+import { ILogger } from "@/utils/logger.js";
+
+const SERVICE_NAME = "SecretsRotationService";
+
+export interface ISecretsRotationService {
+ run(): Promise<{ scanned: number; reencrypted: number; skipped: number }>;
+}
+
+export class SecretsRotationService implements ISecretsRotationService {
+ static SERVICE_NAME = SERVICE_NAME;
+
+ constructor(
+ private monitorsRepository: IMonitorsRepository,
+ private encryptionService: IEncryptionService,
+ private logger: ILogger
+ ) {}
+
+ run = async () => {
+ const rows = await this.monitorsRepository.findAllDockerTlsKeys();
+ const result = { scanned: rows.length, reencrypted: 0, skipped: 0 };
+ if (rows.length === 0) return result;
+
+ if (!this.encryptionService.isConfigured()) {
+ this.logger.warn({
+ message: `${rows.length} monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is`,
+ service: SERVICE_NAME,
+ method: "run",
+ });
+ result.skipped = rows.length;
+ return result;
+ }
+
+ for (const monitor of rows) {
+ const { id, dockerTlsKey } = monitor;
+ try {
+ if (!this.encryptionService.needsReencryption(dockerTlsKey)) continue;
+ const plainText = this.encryptionService.decrypt(dockerTlsKey);
+ await this.monitorsRepository.updateDockerTlsKey(id, this.encryptionService.encrypt(plainText));
+ result.reencrypted += 1;
+ } catch (error: unknown) {
+ result.skipped += 1;
+ this.logger.warn({
+ message: `Could not reencrypt Docker TLS key for monitor ${id}: ${error instanceof Error ? error.message : String(error)}`,
+ service: SERVICE_NAME,
+ method: "run",
+ details: { monitorId: id, ...(error instanceof AppError ? error.details : {}) },
+ });
+ }
+ }
+ this.logger.info({
+ message: `Docker TLS key rotation: ${result.reencrypted} re-encrypted, ${result.skipped} skipped, ${result.scanned} scanned`,
+ service: SERVICE_NAME,
+ method: "run",
+ });
+ return result;
+ };
+}
diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts
index 9f2a99de7..aa8070649 100644
--- a/server/src/service/network/DockerProvider.ts
+++ b/server/src/service/network/DockerProvider.ts
@@ -1,14 +1,45 @@
+import https from "node:https";
+
import { IStatusProvider } from "@/service/network/IStatusProvider.js";
-import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
+import { CheckContext, DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
+
+import {
+ DOCKER_LOG_TAIL_LINES,
+ DockerContainerInfo,
+ DockerContainerLogs,
+ DockerContainerMount,
+ DockerContainerPort,
+ DockerContainerState,
+ DockerContainerStates,
+ DockerContainerSummary,
+ DockerHealthStatus,
+ DockerHealthStatuses,
+ DockerLogLine,
+ DockerLogStream,
+ DockerPortProtocol,
+ DockerPortProtocols,
+} from "@/domain/docker/docker.type.js";
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
import { ILogger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
import Dockerode from "dockerode";
import { timeRequest } from "@/service/network/utils.js";
import { NETWORK_ERROR } from "@/types/network.js";
+import { IEncryptionService } from "@/service/encryption/encryptionService.js";
+import { DOCKER_TLS_URL, isDockerTlsUrl } from "@/utils/dockerHost.js";
+import { splitCertificateBundle } from "@/utils/pem.js";
+
type DockerodeType = typeof Dockerode;
+type DockerOptions = Dockerode.DockerOptions & { agent?: https.Agent; connectionTimeout?: number };
+type TlsCredentials = { ok: true; key: string } | { ok: false; message: string };
const SERVICE_NAME = "DockerProvider";
+const TIMEOUT_MS = 10000;
+const STATS_CONCURRENCY = 5;
+const DOCKER_LOG_MAX_LINE_BYTES = 4096;
+const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]";
+const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /;
+const DOCKER_TLS_DEFAULT_PORT = 2376;
export interface DockerError extends Error {
statusCode?: number;
@@ -18,157 +49,333 @@ export interface DockerError extends Error {
export class DockerProvider implements IStatusProvider {
readonly type = "docker";
- private docker: Dockerode;
+
constructor(
private logger: ILogger,
- private DockerLib: DockerodeType
- ) {
- this.docker = new this.DockerLib();
- }
+ private DockerLib: DockerodeType,
+ private encryptionService: IEncryptionService
+ ) {}
supports(type: MonitorType): boolean {
return type === "docker";
}
+ private resolveTlsCredentials = (monitor: Monitor, ctx?: CheckContext): TlsCredentials | undefined => {
+ if (!isDockerTlsUrl(monitor.url)) return undefined;
+ if (!ctx?.dockerTlsKey) return { ok: false, message: "Docker TLS key is missing" };
+ try {
+ return { ok: true, key: this.encryptionService.decrypt(ctx.dockerTlsKey) };
+ } catch (error) {
+ if (error instanceof AppError) {
+ const keyId = typeof error.details?.keyId === "string" ? ` (key id ${error.details.keyId})` : "";
+ return { ok: false, message: `${error.message}${keyId}` };
+ }
+ return { ok: false, message: "Docker TLS key could not be decrypted" };
+ }
+ };
+
private isDockerError(error: unknown): error is DockerError {
return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error);
}
- async handle(monitor: Monitor): Promise> {
- const { url: containerInput } = monitor;
+ private splitCaBundle = (pem: string | undefined): string[] | undefined => {
+ const blocks = pem ? splitCertificateBundle(pem) : [];
+ return blocks.length > 0 ? blocks : undefined;
+ };
+
+ private invalidUrl = (message: string) => new AppError({ message, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
+
+ private toDockerOptions = (monitor: Monitor, credentials?: TlsCredentials): DockerOptions => {
+ const url = monitor.url?.trim();
+ if (!url) throw this.invalidUrl("Docker host URL is required");
+
+ // Unix socket
+ if (url.startsWith("unix://")) {
+ const socketPath = url.slice("unix://".length);
+ if (!socketPath.startsWith("/")) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
+ return { socketPath, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
+ }
+ if (url.startsWith("/")) return { socketPath: url, timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
+
+ // Docker TLS
+ const match = DOCKER_TLS_URL.exec(url);
+ if (!match) throw this.invalidUrl(`Invalid Docker host URL: ${url}`);
+ if (!credentials?.ok) throw this.invalidUrl(credentials?.message ?? "Docker TLS key is missing");
+ const [, , host, port] = match;
+ const ca = monitor.ignoreTlsErrors ? undefined : this.splitCaBundle(monitor.dockerTlsCa);
+ const cert = monitor.dockerTlsCert;
+ const key = credentials.key;
+ return {
+ host,
+ port: port ? Number(port) : DOCKER_TLS_DEFAULT_PORT,
+ protocol: "https",
+ ca,
+ cert,
+ key,
+ agent: new https.Agent({ ca, cert, key, rejectUnauthorized: !monitor.ignoreTlsErrors }),
+ socketPath: undefined,
+ username: undefined,
+ sshOptions: undefined,
+ timeout: TIMEOUT_MS,
+ connectionTimeout: TIMEOUT_MS,
+ };
+ };
+
+ private async mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise {
+ const results: R[] = [];
+ for (let i = 0; i < items.length; i += limit) {
+ results.push(...(await Promise.all(items.slice(i, i + limit).map(fn))));
+ }
+ return results;
+ }
+
+ private toContainerState(state: string): DockerContainerState {
+ return (DockerContainerStates as readonly string[]).includes(state) ? (state as DockerContainerState) : "created";
+ }
+
+ private toHealthStatus(status: string | undefined): DockerHealthStatus {
+ return status && (DockerHealthStatuses as readonly string[]).includes(status) ? (status as DockerHealthStatus) : "none";
+ }
+
+ private computeCpuPct(stats: Dockerode.ContainerStats): number {
+ const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
+ const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage ?? 0);
+ const onlineCpus = stats.cpu_stats.online_cpus ?? stats.cpu_stats.cpu_usage.percpu_usage?.length ?? 1;
+ if (!(systemDelta > 0) || cpuDelta < 0) return 0;
+ return (cpuDelta / systemDelta) * onlineCpus;
+ }
+
+ private computeMemory(stats: Dockerode.ContainerStats): Pick {
+ const raw = stats.memory_stats.usage ?? 0;
+ const pageCache = stats.memory_stats.stats?.inactive_file ?? stats.memory_stats.stats?.cache ?? 0;
+ const memoryUsedBytes = Math.max(0, raw - pageCache);
+ const memoryLimitBytes = stats.memory_stats.limit ?? 0;
+ return {
+ memoryUsedBytes,
+ memoryLimitBytes,
+ memoryPct: memoryLimitBytes > 0 ? memoryUsedBytes / memoryLimitBytes : 0,
+ };
+ }
+
+ private toContainerName = (summary: Dockerode.ContainerInfo): string => {
+ return summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12);
+ };
+
+ private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise {
+ const info: DockerContainerInfo = {
+ id: summary.Id,
+ name: this.toContainerName(summary),
+ image: summary.Image,
+ state: this.toContainerState(summary.State),
+ status: summary.Status,
+ health: "none",
+ };
+
+ const container = docker.getContainer(summary.Id);
+ const [inspectResult, statsResult] = await Promise.allSettled([
+ container.inspect(),
+ summary.State === "running" ? container.stats({ stream: false }) : Promise.resolve(null),
+ ]);
+
+ if (inspectResult.status === "fulfilled") {
+ info.restartCount = inspectResult.value.RestartCount;
+ info.startedAt = inspectResult.value.State?.StartedAt;
+ info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status);
+ info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports);
+ info.mounts = this.toMounts(inspectResult.value.Mounts);
+ } else {
+ this.logger.warn({
+ message: `Failed to inspect container ${info.name}`,
+ service: SERVICE_NAME,
+ method: "toContainerInfo",
+ details: { containerId: summary.Id },
+ });
+ }
+
+ if (statsResult.status === "fulfilled" && statsResult.value) {
+ info.cpuPct = this.computeCpuPct(statsResult.value);
+ Object.assign(info, this.computeMemory(statsResult.value));
+ }
+
+ return info;
+ }
+
+ private toPortProtocol(protocol: string): DockerPortProtocol {
+ return (DockerPortProtocols as readonly string[]).includes(protocol) ? (protocol as DockerPortProtocol) : "tcp";
+ }
+
+ private toPorts(ports: Dockerode.ContainerInspectInfo["NetworkSettings"]["Ports"] | undefined): DockerContainerPort[] {
+ const result: DockerContainerPort[] = [];
+ for (const [portAndProtocol, bindings] of Object.entries(ports ?? {})) {
+ const [portString, protocolString = ""] = portAndProtocol.split("/");
+ const privatePort = Number(portString);
+ if (!Number.isInteger(privatePort)) continue;
+ const protocol = this.toPortProtocol(protocolString);
+ // Exposed but unpublished ports have no bindings
+ if (!bindings || bindings.length === 0) {
+ result.push({ privatePort, protocol });
+ continue;
+ }
+ for (const binding of bindings) {
+ const publicPort = Number(binding.HostPort);
+ result.push({
+ privatePort,
+ protocol,
+ publicPort: Number.isInteger(publicPort) ? publicPort : undefined,
+ hostIp: binding.HostIp || undefined,
+ });
+ }
+ }
+ return result;
+ }
+
+ private toMounts(mounts: Dockerode.ContainerInspectInfo["Mounts"] | undefined): DockerContainerMount[] {
+ return (mounts ?? []).map((mount) => ({
+ type: mount.Type ?? "",
+ name: mount.Name,
+ source: mount.Source ?? "",
+ destination: mount.Destination ?? "",
+ mode: mount.Mode ?? "",
+ rw: mount.RW ?? true,
+ }));
+ }
+
+ // Log parsing
+ private isMultiplexed(buffer: Buffer): boolean {
+ return buffer.length >= 8 && buffer.readUInt8(0) <= 2 && buffer.readUInt8(1) === 0 && buffer.readUInt8(2) === 0 && buffer.readUInt8(3) === 0;
+ }
+
+ // Docker will will merge stdout and stderr if stared with `-t`.
+ // Most containers will _not_ be run with `-t` though, in which case logs come in frames of [header][payload]
+ private demuxLogBuffer = (buffer: Buffer): { stream: DockerLogStream; chunk: string }[] => {
+ if (!this.isMultiplexed(buffer)) return [{ stream: "stdout", chunk: buffer.toString("utf8") }]; // `-t` case, log is just a string, return it
+ const frames: { stream: DockerLogStream; chunk: string }[] = [];
+ let offset = 0; // Start reading at the first byte of the first header
+ while (offset + 8 <= buffer.length) {
+ // Every 8 bytes is a header, if < 8 bytes at the end, invalid frame, stop
+ const streamType = buffer.readUint8(offset); // First byte of the header is the stream type: 0 = stdin, 1 = stdout, 2 = stderr
+ const payloadSize = buffer.readUint32BE(offset + 4); // Byte 4 to 7 describe how many payload bytes follow the header
+ const payloadStart = offset + 8; // Advance to the payload (header 8 bytes long)
+ const payloadEnd = Math.min(payloadStart + payloadSize, buffer.length); // End is the start of the payload + payload size.
+ frames.push({ stream: streamType === 2 ? "stderr" : "stdout", chunk: buffer.toString("utf-8", payloadStart, payloadEnd) });
+ offset = payloadEnd;
+ }
+ return frames;
+ };
+
+ private truncateLog = (text: string): string => {
+ if (Buffer.byteLength(text, "utf8") <= DOCKER_LOG_MAX_LINE_BYTES) return text;
+ const cut = Buffer.from(text, "utf8").subarray(0, DOCKER_LOG_MAX_LINE_BYTES).toString("utf8").replace(/�$/, "");
+ return cut + DOCKER_LOG_TRUNCATION_MARKER;
+ };
+
+ // Docker drops trailing zeroes form timestamps. Pad out timestamps for correct string comparison
+ private toLogLine(stream: DockerLogStream, raw: string): DockerLogLine | null {
+ const match = raw.match(DOCKER_LOG_TS_REGEX);
+ if (!match) return null; // If there's no time stamp, something is wrong with this line, drop it
+ const ts = `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`;
+ const text = raw.slice(match[0].length).replace(/\r$/, "");
+ return { ts, stream, text: this.truncateLog(text) };
+ }
+
+ private parseLogBuffer = (buffer: Buffer): DockerLogLine[] => {
+ const lines: DockerLogLine[] = [];
+ const demuxedBuffer = this.demuxLogBuffer(buffer);
+ for (const byteStream of demuxedBuffer) {
+ const { stream, chunk } = byteStream;
+ for (const raw of chunk.split("\n")) {
+ if (raw.length === 0) continue;
+ const line = this.toLogLine(stream, raw);
+ if (line) lines.push(line);
+ }
+ }
+ return lines;
+ };
+
+ private toContainerLogs = async (docker: Dockerode, summary: Dockerode.ContainerInfo): Promise => {
+ const containerName = this.toContainerName(summary);
try {
- if (!containerInput) {
- throw new Error("Container name or ID is required for Docker monitor");
- }
+ const buffer = await docker
+ .getContainer(summary.Id)
+ .logs({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES });
+ return { containerId: summary.Id, containerName, lines: this.parseLogBuffer(buffer) };
+ } catch (error: unknown) {
+ this.logger.warn({
+ message: `Failed to read logs for container ${containerName}`,
+ service: SERVICE_NAME,
+ method: "toContainerLogs",
+ details: { containerId: summary.Id, error: error instanceof Error ? error.message : String(error) },
+ });
+ return null;
+ }
+ };
- const containers = await this.docker.listContainers({ all: true });
- const normalizedInput = containerInput.replace(/^\/+/, "").toLowerCase();
+ private collectLogs = async (docker: Dockerode, summaries: Dockerode.ContainerInfo[]): Promise => {
+ const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s));
+ return rawLogs.filter((log) => log !== null);
+ };
- // Priority-based matching to avoid ambiguity:
- // 1. Exact full ID match (64-char)
- const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput);
+ private failed = (monitor: Monitor, code: number, message: string, responseTime: number): MonitorStatusResponse => ({
+ monitorId: monitor.id,
+ teamId: monitor.teamId,
+ type: monitor.type,
+ status: false,
+ code,
+ message,
+ responseTime,
+ payload: null,
+ });
- // 2. Exact container name match (case-insensitive)
- const exactNameMatch = containers.find((c) =>
- c.Names.some((name: string) => {
- const cleanName = name.replace(/^\/+/, "").toLowerCase();
- return cleanName === normalizedInput;
- })
- );
-
- // 3. Partial ID match (fallback for backwards compatibility)
- const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput));
-
- // Select container based on priority
- const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch;
-
- // Handle no match
- if (!targetContainer) {
- this.logger.warn({
- message: `No container found for "${monitor.url}".`,
- service: SERVICE_NAME,
- method: "handle",
- details: { url: monitor.url },
- });
-
- return {
- monitorId: monitor.id,
- teamId: monitor.teamId,
- type: monitor.type,
- status: false,
- code: 404,
- message: "Docker container not found",
- responseTime: 0,
- payload: null,
- };
- }
-
- // 5. Handle Ambiguity Check
- const matchTypes: string[] = [];
- if (exactIdMatch) matchTypes.push("exact ID");
- if (exactNameMatch) matchTypes.push("exact name");
- if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
-
- if (matchTypes.length > 1) {
- const matchUsed = exactIdMatch ? "exact ID" : "exact name";
- const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
-
- this.logger.warn({
- message,
- service: SERVICE_NAME,
- method: "handle",
- details: { url: containerInput },
- });
-
- return {
- monitorId: monitor.id,
- teamId: monitor.teamId,
- type: monitor.type,
- status: false,
- code: NETWORK_ERROR,
- message,
- responseTime: 0,
- payload: null,
- };
- }
-
- // 6. Inspect Container Status
- const container = this.docker.getContainer(targetContainer.Id);
- const { response, responseTime, error } = await timeRequest(() => container.inspect());
+ handle = async (monitor: Monitor, ctx?: CheckContext): Promise> => {
+ try {
+ const credentials = this.resolveTlsCredentials(monitor, ctx);
+ if (credentials && !credentials.ok) return this.failed(monitor, NETWORK_ERROR, credentials.message, 0);
+ const docker = new this.DockerLib(this.toDockerOptions(monitor, credentials));
+ // Host reachability is the monitor's status; ping latency is the responseTime
+ const { responseTime, error } = await timeRequest(() => docker.ping());
if (error) {
- let message = "Failed to fetch Docker container information";
+ let message = "Docker host is unreachable";
let code = NETWORK_ERROR;
-
if (this.isDockerError(error)) {
code = error.statusCode ?? NETWORK_ERROR;
message = error.json?.message ?? error.reason ?? error.message;
} else if (error instanceof Error) {
message = error.message;
}
-
- return {
- monitorId: monitor.id,
- teamId: monitor.teamId,
- type: monitor.type,
- status: false,
- code: code,
- message: message,
- responseTime,
- payload: null,
- };
+ return this.failed(monitor, code, message, responseTime);
}
- if (!response) {
- return {
- monitorId: monitor.id,
- teamId: monitor.teamId,
- type: monitor.type,
- status: false,
- code: NETWORK_ERROR,
- message: "No response from Docker container inspect",
- responseTime,
- payload: null,
- };
- }
+ const summaries = await docker.listContainers({ all: true });
+ const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s));
+ const logs = monitor.dockerLogsEnabled ? await this.collectLogs(docker, summaries) : undefined;
+ const summary: DockerContainerSummary = {
+ total: containers.length,
+ running: containers.filter((c) => c.state === "running").length,
+ stopped: containers.filter((c) => c.state === "exited" || c.state === "dead").length,
+ unhealthy: containers.filter((c) => c.health === "unhealthy").length,
+ };
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
- status: response.State?.Status === "running",
+ status: true,
code: 200,
- message: "Docker container status fetched successfully",
+ message: "Docker host is reachable",
responseTime,
- payload: response as unknown as DockerStatusPayload,
+ payload: { containers, summary, logs },
};
- } catch (err: unknown) {
+ } catch (error: unknown) {
+ if (error instanceof AppError) throw error;
throw new AppError({
- message: err instanceof Error ? err.message : "Error performing Docker request",
+ message: error instanceof Error ? error.message : "Error performing Docker request",
service: SERVICE_NAME,
method: "handle",
- details: { url: containerInput },
+ details: {
+ url: monitor.url,
+ },
});
}
- }
+ };
}
diff --git a/server/src/types/network.ts b/server/src/types/network.ts
index 0c8dd7cae..1f8afe267 100644
--- a/server/src/types/network.ts
+++ b/server/src/types/network.ts
@@ -9,6 +9,7 @@ import type {
GotTimings,
ILighthouseAudit,
} from "@/domain/checks/check.type.js";
+import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js";
import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js";
import type { QueryResult } from "gamedig";
@@ -96,7 +97,13 @@ export interface HardwareStatusPayload {
[key: string]: unknown;
}
-export type DockerStatusPayload = Record;
+// Docker host monitoring
+
+export interface DockerStatusPayload {
+ containers: DockerContainerInfo[];
+ summary: DockerContainerSummary;
+ logs?: DockerContainerLogs[];
+}
export interface PortStatusPayload {
success: boolean;
@@ -155,4 +162,5 @@ export type MonitorStatusResponseOverrides = Partial DOCKER_TLS_URL.test(url?.trim() ?? "");
+export const isDockerSocketUrl = (url: string | undefined): boolean => DOCKER_SOCKET_URL.test(url?.trim() ?? "");
diff --git a/server/src/utils/pem.ts b/server/src/utils/pem.ts
new file mode 100644
index 000000000..13e7be106
--- /dev/null
+++ b/server/src/utils/pem.ts
@@ -0,0 +1,28 @@
+import { createPrivateKey, KeyObject, X509Certificate } from "node:crypto";
+
+const PEM_CERT_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
+
+export const splitCertificateBundle = (pem: string): string[] => pem.match(PEM_CERT_BLOCK) ?? [];
+
+export const parseCertificates = (pem: string): X509Certificate[] => {
+ const blocks = splitCertificateBundle(pem);
+ if (blocks.length === 0) throw new Error("No certificate found; expected one or more PEM CERTIFICATE blocks");
+ try {
+ return blocks.map((block) => new X509Certificate(block));
+ } catch {
+ throw new Error("Certificate is not valid PEM");
+ }
+};
+
+export const parsePrivateKey = (pem: string): KeyObject => {
+ if (/^-----BEGIN ENCRYPTED PRIVATE KEY-----/m.test(pem) || /^Proc-Type: 4,ENCRYPTED/m.test(pem)) {
+ throw new Error("Encrypted private keys are not supported; remove the passphrase first");
+ }
+ try {
+ return createPrivateKey(pem);
+ } catch {
+ throw new Error("Private key is not valid PEM");
+ }
+};
+
+export const keyMatchesCertificate = (key: KeyObject, certificate: X509Certificate): boolean => certificate.checkPrivateKey(key);
diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts
index f297d01be..603eec8b6 100644
--- a/server/src/worker/worker.check-producer.ts
+++ b/server/src/worker/worker.check-producer.ts
@@ -1,6 +1,6 @@
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { Check } from "@/domain/checks/check.type.js";
-import { MonitorStatusResponse } from "@/types/network.js";
+import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "@/service/bufferService.js";
@@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
import { isWindowActive } from "@/utils/maintenanceWindow.js";
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
+import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
export interface ICheckProducer {
produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>;
@@ -25,6 +26,7 @@ export class CheckProducer implements ICheckProducer {
private networkService: INetworkService,
private proxyResolver: IProxyResolver,
private bufferService: IBufferService,
+ private dockerLogsService: IDockerLogsService,
private logger: ILogger
) {}
@@ -34,6 +36,12 @@ export class CheckProducer implements ICheckProducer {
return windows.some((w) => isWindowActive(w, now));
}
+ private async resolveDockerTlsKey(monitor: Monitor): Promise {
+ if (monitor.type !== "docker" || !monitor.dockerTlsKeySet || !monitor.id) return undefined;
+ const dockerTlsKey = await this.monitorsRepository.findDockerTlsKeyById(monitor.id);
+ return dockerTlsKey ?? undefined;
+ }
+
produce = async (monitor: Monitor) => {
if (!monitor.id) {
throw new AppError({ message: "No monitor id", service: SERVICE_NAME, method: "produce" });
@@ -59,7 +67,9 @@ export class CheckProducer implements ICheckProducer {
// Step 1b: Acquire status
const proxyUrl = await this.proxyResolver.resolve(monitor);
- const status = await this.networkService.requestStatus(monitor, { proxyUrl });
+ const dockerTlsKey = await this.resolveDockerTlsKey(monitor);
+
+ const status = await this.networkService.requestStatus(monitor, { proxyUrl, dockerTlsKey });
if (!status) {
throw new Error("No network response");
}
@@ -81,6 +91,14 @@ export class CheckProducer implements ICheckProducer {
}
// Step 2b: Add to buffer
this.bufferService.addToBuffer(check);
+
+ // Step 2c: Handle docker logs
+ if (status.type === "docker") {
+ const dockerLogs = await this.dockerLogsService.buildDockerLogs(status as MonitorStatusResponse);
+ for (const dockerLog of dockerLogs) {
+ this.bufferService.addDockerLogToBuffer(dockerLog);
+ }
+ }
return { status, check };
};
}
diff --git a/server/src/worker/worker.helper.ts b/server/src/worker/worker.helper.ts
index 9f044763d..d8a35e0b2 100644
--- a/server/src/worker/worker.helper.ts
+++ b/server/src/worker/worker.helper.ts
@@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { ILogger } from "@/utils/logger.js";
+import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
export interface IWorkerHelper {
getCleanupOrphanedJob(): () => Promise;
@@ -43,6 +44,7 @@ export class WorkerHelper implements IWorkerHelper {
private checksRepository: IChecksRepository;
private incidentsRepository: IIncidentsRepository;
private geoChecksRepository: IGeoChecksRepository;
+ private dockerLogsRepository: IDockerLogsRepository;
constructor(
logger: ILogger,
@@ -54,7 +56,8 @@ export class WorkerHelper implements IWorkerHelper {
monitorStatsRepository: IMonitorStatsRepository,
checksRepository: IChecksRepository,
incidentsRepository: IIncidentsRepository,
- geoChecksRepository: IGeoChecksRepository
+ geoChecksRepository: IGeoChecksRepository,
+ dockerLogsRepository: IDockerLogsRepository
) {
this.logger = logger;
this.checkService = checkService;
@@ -66,6 +69,7 @@ export class WorkerHelper implements IWorkerHelper {
this.checksRepository = checksRepository;
this.incidentsRepository = incidentsRepository;
this.geoChecksRepository = geoChecksRepository;
+ this.dockerLogsRepository = dockerLogsRepository;
}
getCleanupOrphanedJob = () => {
@@ -142,6 +146,15 @@ export class WorkerHelper implements IWorkerHelper {
});
}
+ const deletedDockerLogsCount = await this.dockerLogsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
+ if (deletedDockerLogsCount > 0) {
+ this.logger.info({
+ message: `Deleted ${deletedDockerLogsCount} orphaned docker logs`,
+ service: SERVICE_NAME,
+ method: "getCleanupOrphanedJob",
+ });
+ }
+
// Remove orphaned jobs
const deletedJobsCount = await this.jobsRepository.deleteByMonitorIdsNotIn(allMonitorIds);
if (deletedJobsCount > 0) {
diff --git a/server/test/fixtures/docker-tls/README.md b/server/test/fixtures/docker-tls/README.md
new file mode 100644
index 000000000..c4183c6e7
--- /dev/null
+++ b/server/test/fixtures/docker-tls/README.md
@@ -0,0 +1,10 @@
+Test fixtures for Docker TLS validation. Self-signed, 100-year, not trusted anywhere.
+
+Regenerate with:
+
+ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 36500 -subj "/CN=Checkmate Test CA" -keyout ca-key.pem -out ca.pem
+ openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=checkmate-test-client" -keyout client-key.pem -out client.csr
+ openssl x509 -req -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -days 36500 -out client-cert.pem
+ openssl ecparam -name prime256v1 -genkey -noout -out other-key.pem
+ openssl pkcs8 -topk8 -in client-key.pem -out encrypted-key.pem -passout pass:fixture
+ rm client.csr ca.srl ca-key.pem
diff --git a/server/test/fixtures/docker-tls/ca.pem b/server/test/fixtures/docker-tls/ca.pem
new file mode 100644
index 000000000..67f23b873
--- /dev/null
+++ b/server/test/fixtures/docker-tls/ca.pem
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBkDCCATWgAwIBAgIUL3SODzLIekF+Tc3iHhHr/7uxvdIwCgYIKoZIzj0EAwIw
+HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP
+MjEyNjA4MTcxOTE0NTJaMBwxGjAYBgNVBAMMEUNoZWNrbWF0ZSBUZXN0IENBMFkw
+EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXRJ0Y0jc+pKwXbuioDmbaHOqr5n3oU9x
+Y16vYajhUiFeWohEqGRG8lHA9S82KPNmpjthLWUykHvrTsKosBb6IKNTMFEwHQYD
+VR0OBBYEFNfGlRFC2MFZrCkoF4UxktbZUve1MB8GA1UdIwQYMBaAFNfGlRFC2MFZ
+rCkoF4UxktbZUve1MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIh
+APOnYaaKk5ED6NV/AkdpNXOEqlWcXwIQzh5Ycj3cjby+AiEApuAGTnk8k4dtVNmB
+aVV5bRmq7f3BjhxwZorP+KiIrdI=
+-----END CERTIFICATE-----
diff --git a/server/test/fixtures/docker-tls/client-cert.pem b/server/test/fixtures/docker-tls/client-cert.pem
new file mode 100644
index 000000000..19e6e9fee
--- /dev/null
+++ b/server/test/fixtures/docker-tls/client-cert.pem
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBgjCCASigAwIBAgIUBx7r8tVOUFNX8c3YYyBxXTNltuUwCgYIKoZIzj0EAwIw
+HDEaMBgGA1UEAwwRQ2hlY2ttYXRlIFRlc3QgQ0EwIBcNMjYwOTEwMTkxNDUyWhgP
+MjEyNjA4MTcxOTE0NTJaMCAxHjAcBgNVBAMMFWNoZWNrbWF0ZS10ZXN0LWNsaWVu
+dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKeUUNe9kE57nK7wTfnnvmqgzVj1
+ngNPajQYIAh9SAR+pVLAoTWLinWq5YarW1LFAzxyRKl6p2NdKtyLpVYV5BKjQjBA
+MB0GA1UdDgQWBBReuZBD6K0kWfgGDkt05zzM24wPejAfBgNVHSMEGDAWgBTXxpUR
+QtjBWawpKBeFMZLW2VL3tTAKBggqhkjOPQQDAgNIADBFAiAvfzha4dEVcJlKjH45
+yOkbFXiS/+tU+uTvn+7uutn6QAIhAI2qCXVvxX+i3Jk1xTS7R0mzJNXZXqabjODi
+cisS1VJG
+-----END CERTIFICATE-----
diff --git a/server/test/fixtures/docker-tls/client-key.pem b/server/test/fixtures/docker-tls/client-key.pem
new file mode 100644
index 000000000..42e0f795c
--- /dev/null
+++ b/server/test/fixtures/docker-tls/client-key.pem
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIHAuDMyYVMOi5PpA
+yUPjuNjvy/QOI9dnjQZ+AKhuQO+hRANCAASnlFDXvZBOe5yu8E35575qoM1Y9Z4D
+T2o0GCAIfUgEfqVSwKE1i4p1quWGq1tSxQM8ckSpeqdjXSrci6VWFeQS
+-----END PRIVATE KEY-----
diff --git a/server/test/fixtures/docker-tls/encrypted-key.pem b/server/test/fixtures/docker-tls/encrypted-key.pem
new file mode 100644
index 000000000..6be1a4a07
--- /dev/null
+++ b/server/test/fixtures/docker-tls/encrypted-key.pem
@@ -0,0 +1,8 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDak7FU8VAgafGR1iEl
+eFHbAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQCUTF7P/8uj9a5kLU
+3nBJdQSBkNpsmjC1FczojCBOn0g+2PlmMGI6MfR3vFvmPRAAF7SHLzQC1lXWhTU0
+3v5yCCdjlC5gTGINKf8RhZQjKym+lIpf+16EAPFLzFbRJPvt/eKq65YiixvQP2PS
+vysOG71YZJt1kSHrCOu5EPcCLvD2Kjyt48c/LJssCB81TOdqP1jV9iH29SCv4bnx
+KtjXulGg3w==
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/server/test/fixtures/docker-tls/other-key.pem b/server/test/fixtures/docker-tls/other-key.pem
new file mode 100644
index 000000000..e671633f7
--- /dev/null
+++ b/server/test/fixtures/docker-tls/other-key.pem
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIGnUNFbPMxfa3m7xo9J3Lu9qiss2ZxWpqp91B6X9rQbfoAoGCCqGSM49
+AwEHoUQDQgAEGtbNBALMRIEROLtiEdNda4r/X0Hq1hcY/0V20gW3hEHhKRKkSilH
+0LyFWtlZ5uILZh6zXCGWlOPm0PFO9rN/hQ==
+-----END EC PRIVATE KEY-----
diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts
index 9c2161790..0661d6445 100644
--- a/server/test/helpers/heartbeatTestHarness.ts
+++ b/server/test/helpers/heartbeatTestHarness.ts
@@ -128,6 +128,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) };
+ const dockerLogsService = { buildDockerLogs: jest.fn().mockResolvedValue([]) };
const checkProducer = new CheckProducer(
monitorsRepo as any,
@@ -136,6 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
networkService as any,
proxyResolver as any,
bufferStub as any,
+ dockerLogsService as any,
logger
);
const checkEvaluator = new CheckEvaluator(statusService as any, new MonitorStatusPolicy());
diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts
index ed7a7a7a6..63ef4a105 100644
--- a/server/test/integration/monitorRepository.test.ts
+++ b/server/test/integration/monitorRepository.test.ts
@@ -123,4 +123,70 @@ describe("MongoMonitorsRepository", () => {
expect(monitors[0].recentChecks).toEqual([]);
});
});
+
+ describe("docker tls key access", () => {
+ const seedDockerMonitor = (overrides: Record = {}) =>
+ MonitorModel.create({
+ userId: new mongoose.Types.ObjectId(),
+ teamId: new mongoose.Types.ObjectId(),
+ name: "docker",
+ type: "docker",
+ url: "tcp://host",
+ ...overrides,
+ });
+
+ it("never returns the key on a normal load, even with the flag set", async () => {
+ const repo = new MongoMonitorsRepository();
+ const doc = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.iv.tag.data", dockerTlsKeySet: true });
+
+ const monitor = await repo.findByIdLean(doc._id.toString());
+
+ expect(monitor?.dockerTlsKeySet).toBe(true);
+ expect(monitor?.dockerTlsKey).toBeUndefined();
+ });
+
+ it("findDockerTlsKeyById returns the stored key despite select: false", async () => {
+ const repo = new MongoMonitorsRepository();
+ const doc = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.iv.tag.data", dockerTlsKeySet: true });
+
+ await expect(repo.findDockerTlsKeyById(doc._id.toString())).resolves.toBe("v1.abc123.iv.tag.data");
+ });
+
+ it("findDockerTlsKeyById returns null for a monitor without a key and for an unknown id", async () => {
+ const repo = new MongoMonitorsRepository();
+ const doc = await seedDockerMonitor({ url: "unix:///var/run/docker.sock" });
+
+ await expect(repo.findDockerTlsKeyById(doc._id.toString())).resolves.toBeNull();
+ await expect(repo.findDockerTlsKeyById(new mongoose.Types.ObjectId().toString())).resolves.toBeNull();
+ });
+
+ it("findAllDockerTlsKeys returns only rows with the flag set and a key present", async () => {
+ const repo = new MongoMonitorsRepository();
+ const keyed = await seedDockerMonitor({ dockerTlsKey: "v1.abc123.k1", dockerTlsKeySet: true });
+ await seedDockerMonitor({ url: "unix:///var/run/docker.sock" });
+ await seedDockerMonitor({ dockerTlsKeySet: true }); // flag without key: inconsistent, dropped
+ await MonitorModel.create({
+ userId: new mongoose.Types.ObjectId(),
+ teamId: new mongoose.Types.ObjectId(),
+ name: "http",
+ type: "http",
+ url: "https://example.com",
+ });
+
+ await expect(repo.findAllDockerTlsKeys()).resolves.toEqual([{ id: keyed._id.toString(), dockerTlsKey: "v1.abc123.k1" }]);
+ });
+
+ it("updateDockerTlsKey replaces the key and nothing else", async () => {
+ const repo = new MongoMonitorsRepository();
+ const doc = await seedDockerMonitor({ dockerTlsKey: "v1.old.k", dockerTlsKeySet: true, dockerTlsCert: "cert" });
+
+ await repo.updateDockerTlsKey(doc._id.toString(), "v1.new.k");
+
+ const after = await MonitorModel.findById(doc._id).select("+dockerTlsKey").lean();
+ expect(after?.dockerTlsKey).toBe("v1.new.k");
+ expect(after?.dockerTlsCert).toBe("cert");
+ expect(after?.dockerTlsKeySet).toBe(true);
+ expect(after?.name).toBe("docker");
+ });
+ });
});
diff --git a/server/test/unit/middleware/isAllowed.test.ts b/server/test/unit/middleware/isAllowed.test.ts
new file mode 100644
index 000000000..24449dcde
--- /dev/null
+++ b/server/test/unit/middleware/isAllowed.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, jest } from "@jest/globals";
+import type { NextFunction, Request, Response } from "express";
+import { isAllowed } from "../../../src/api/middleware/isAllowed.ts";
+import { AppError } from "../../../src/utils/AppError.ts";
+
+describe("isAllowed middleware", () => {
+ it("calls next() without error when user has an allowed role", () => {
+ const middleware = isAllowed(["admin", "superadmin"]);
+ const req = {
+ user: {
+ id: "user-1",
+ teamId: "team-1",
+ email: "admin@example.com",
+ role: ["admin"],
+ },
+ } as unknown as Request;
+ const res = {} as Response;
+ const next = jest.fn() as unknown as NextFunction;
+
+ middleware(req, res, next);
+
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it("calls next(error) with 403 when user does not have an allowed role", () => {
+ const middleware = isAllowed(["admin", "superadmin"]);
+ const req = {
+ user: {
+ id: "user-2",
+ teamId: "team-1",
+ email: "user@example.com",
+ role: ["user"],
+ },
+ } as unknown as Request;
+ const res = {} as Response;
+ const next = jest.fn() as unknown as NextFunction;
+
+ middleware(req, res, next);
+
+ expect(next).toHaveBeenCalledTimes(1);
+ const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
+ expect(error).toBeInstanceOf(AppError);
+ expect(error.status).toBe(403);
+ expect(error.message).toBe("Unauthorized");
+ });
+
+ it("calls next(error) with 403 when req.user is undefined", () => {
+ const middleware = isAllowed(["admin", "superadmin"]);
+ const req = {} as Request;
+ const res = {} as Response;
+ const next = jest.fn() as unknown as NextFunction;
+
+ middleware(req, res, next);
+
+ expect(next).toHaveBeenCalledTimes(1);
+ const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError;
+ expect(error).toBeInstanceOf(AppError);
+ expect(error.status).toBe(403);
+ expect(error.message).toBe("Unauthorized");
+ });
+
+ it("calls next() when user has multiple roles and at least one matches", () => {
+ const middleware = isAllowed(["admin", "superadmin"]);
+ const req = {
+ user: {
+ id: "user-3",
+ teamId: "team-1",
+ email: "multi@example.com",
+ role: ["user", "admin"],
+ },
+ } as unknown as Request;
+ const res = {} as Response;
+ const next = jest.fn() as unknown as NextFunction;
+
+ middleware(req, res, next);
+
+ expect(next).toHaveBeenCalledWith();
+ });
+});
diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts
index 6d16b3e5c..58b2d7903 100644
--- a/server/test/unit/providers/network/dockerProvider.test.ts
+++ b/server/test/unit/providers/network/dockerProvider.test.ts
@@ -1,53 +1,131 @@
import { describe, expect, it, jest } from "@jest/globals";
+import https from "node:https";
import { DockerProvider } from "../../../../src/service/network/DockerProvider.ts";
+import type { IEncryptionService } from "../../../../src/service/encryption/encryptionService.ts";
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
+import { AppError } from "../../../../src/utils/AppError.ts";
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
+import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type.ts";
-// ── Helpers ──────────────────────────────────────────────────────────────────
+// ── Fixtures ─────────────────────────────────────────────────────────────────
+
+const CA_PEM = "-----BEGIN CERTIFICATE-----\nca-one\n-----END CERTIFICATE-----";
+const CA_BUNDLE_PEM = `${CA_PEM}\n-----BEGIN CERTIFICATE-----\nca-two\n-----END CERTIFICATE-----\n`;
+const CERT_PEM = "-----BEGIN CERTIFICATE-----\nclient\n-----END CERTIFICATE-----";
+const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----";
+const KEY_CIPHERTEXT = "v1.abc123.iv.tag.data";
+
+const makeEncryptionService = (overrides?: Partial): IEncryptionService => ({
+ isConfigured: jest.fn(() => true),
+ currentKeyId: jest.fn(() => "abc123"),
+ keyIdOf: jest.fn(() => "abc123"),
+ needsReencryption: jest.fn(() => false),
+ encrypt: jest.fn(() => KEY_CIPHERTEXT),
+ decrypt: jest.fn(() => KEY_PEM),
+ ...overrides,
+});
+
+const TIMEOUT_MS = 10000;
+const TIMEOUTS = { timeout: TIMEOUT_MS, connectionTimeout: TIMEOUT_MS };
+
+const makeTlsMonitor = (overrides?: Partial): Monitor =>
+ makeMonitor({ url: "tcp://host", dockerTlsCa: CA_PEM, dockerTlsCert: CERT_PEM, dockerTlsKeySet: true, ...overrides });
const makeMonitor = (overrides?: Partial): Monitor =>
({
id: "mon-1",
teamId: "team-1",
type: "docker",
- url: "my-container",
+ url: "unix:///var/run/docker.sock",
...overrides,
}) as Monitor;
const makeContainer = (overrides?: Record) => ({
Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
Names: ["/my-container"],
+ Image: "nginx:latest",
State: "running",
+ Status: "Up 3 hours (healthy)",
...overrides,
});
-const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => {
- const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } });
- const getContainer = jest.fn().mockReturnValue({ inspect });
+const makeInspect = (overrides?: Record) => ({
+ RestartCount: 2,
+ State: { StartedAt: "2026-08-28T10:00:00.000Z", Health: { Status: "healthy" } },
+ ...overrides,
+});
- const instance = {
- listContainers: jest.fn().mockResolvedValue(containers),
- getContainer,
- };
+// cpuDelta 200M over systemDelta 1000M on 4 cpus → 80%; memory 300MiB raw - 100MiB page cache = 200MiB of 1GiB → 19.53125%
+const makeStats = (overrides?: Record) => ({
+ cpu_stats: {
+ cpu_usage: { total_usage: 400_000_000 },
+ system_cpu_usage: 2_000_000_000,
+ online_cpus: 4,
+ },
+ precpu_stats: {
+ cpu_usage: { total_usage: 200_000_000 },
+ system_cpu_usage: 1_000_000_000,
+ },
+ memory_stats: {
+ usage: 300 * 1024 * 1024,
+ limit: 1024 * 1024 * 1024,
+ stats: { inactive_file: 100 * 1024 * 1024 },
+ },
+ ...overrides,
+});
- const DockerLib = jest.fn().mockReturnValue(instance) as any;
-
- return { DockerLib, instance, inspect, getContainer };
+const makeLogFrame = (streamType: number, text: string): Buffer => {
+ const payload = Buffer.from(text);
+ const header = Buffer.alloc(8);
+ header.writeUInt8(streamType, 0);
+ header.writeUInt32BE(payload.length, 4);
+ return Buffer.concat([header, payload]);
};
-const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => {
+const setup = (
+ overrides: Partial<{
+ ping: any;
+ listContainers: any;
+ getContainer: any;
+ inspect: any;
+ stats: any;
+ logs: any;
+ encryptionService: IEncryptionService;
+ }> = {}
+) => {
+ const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect());
+ const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats());
+ const logs = overrides.logs ?? jest.fn().mockResolvedValue(Buffer.alloc(0));
+ const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats, logs });
+ const instance = {
+ ping: overrides.ping ?? jest.fn().mockResolvedValue("OK"),
+ listContainers: overrides.listContainers ?? jest.fn().mockResolvedValue([makeContainer()]),
+ getContainer,
+ };
+ const DockerLib = jest.fn().mockReturnValue(instance) as any;
const logger = createMockLogger();
- const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult);
- const provider = new DockerProvider(logger as any, DockerLib);
- return { provider, logger, docker: instance, inspect, getContainer };
+ const encryptionService = overrides.encryptionService ?? makeEncryptionService();
+ const provider = new DockerProvider(logger as any, DockerLib, encryptionService);
+ return {
+ provider,
+ logger,
+ DockerLib,
+ encryptionService,
+ ping: instance.ping,
+ listContainers: instance.listContainers,
+ getContainer,
+ inspect,
+ stats,
+ logs,
+ };
};
// ── Contract ─────────────────────────────────────────────────────────────────
testStatusProviderContract("DockerProvider", {
- create: () => createProvider().provider,
+ create: () => setup().provider,
supportedType: "docker",
unsupportedType: "http",
makeMonitor: () => makeMonitor(),
@@ -56,286 +134,565 @@ testStatusProviderContract("DockerProvider", {
// ── Tests ────────────────────────────────────────────────────────────────────
describe("DockerProvider", () => {
- // ── Container matching ───────────────────────────────────────────────
+ // ── Host url parsing ─────────────────────────────────────────────────
- describe("container matching", () => {
- it("matches by exact container name", async () => {
- const { provider } = createProvider();
+ describe("host url parsing", () => {
+ it("parses unix:// urls into socketPath", async () => {
+ const { provider, DockerLib } = setup();
- const result = await provider.handle(makeMonitor({ url: "my-container" }));
+ await provider.handle(makeMonitor({ url: "unix:///run/docker.sock" }));
+
+ expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock", ...TIMEOUTS });
+ });
+
+ it("parses bare absolute paths into socketPath", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeMonitor({ url: "/var/run/docker.sock" }));
+
+ expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock", ...TIMEOUTS });
+ });
+
+ it("trims surrounding whitespace", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeMonitor({ url: " /var/run/docker.sock " }));
+
+ expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock", ...TIMEOUTS });
+ });
+ });
+
+ // ── TLS host urls ────────────────────────────────────────────────────
+
+ describe("tls host urls", () => {
+ const ctx = { dockerTlsKey: KEY_CIPHERTEXT };
+ const optionsPassedTo = (DockerLib: jest.Mock) => DockerLib.mock.calls[0]?.[0] as Record;
+ const agentOf = (options: Record) => options.agent as https.Agent & { options: Record };
+
+ it("parses tcp:// into an https connection with the monitor's ca and cert and the decrypted key", async () => {
+ const { provider, DockerLib, encryptionService } = setup();
+
+ await provider.handle(makeTlsMonitor(), ctx);
+
+ expect(encryptionService.decrypt).toHaveBeenCalledWith(KEY_CIPHERTEXT);
+ const options = optionsPassedTo(DockerLib);
+ expect(options).toMatchObject({ host: "host", port: 2376, protocol: "https", ca: [CA_PEM], cert: CERT_PEM, key: KEY_PEM });
+ expect(agentOf(options)).toBeInstanceOf(https.Agent);
+ expect(agentOf(options).options).toMatchObject({ ca: [CA_PEM], cert: CERT_PEM, key: KEY_PEM, rejectUnauthorized: true });
+ });
+
+ it("parses https:// with an explicit port", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeTlsMonitor({ url: "https://host:2377" }), ctx);
+
+ expect(optionsPassedTo(DockerLib)).toMatchObject({ host: "host", port: 2377, protocol: "https" });
+ });
+
+ it("accepts a trailing slash", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeTlsMonitor({ url: "tcp://host:2376/" }), ctx);
+
+ expect(optionsPassedTo(DockerLib)).toMatchObject({ host: "host", port: 2376 });
+ });
+
+ it("splits a ca bundle into one entry per certificate on the options and the agent", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeTlsMonitor({ dockerTlsCa: CA_BUNDLE_PEM }), ctx);
+
+ const options = optionsPassedTo(DockerLib);
+ expect(options.ca).toHaveLength(2);
+ expect(agentOf(options).options.ca).toEqual(options.ca);
+ });
+
+ it("omits the ca and stops rejecting unauthorized certificates when ignoreTlsErrors is set", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeTlsMonitor({ ignoreTlsErrors: true }), ctx);
+
+ const options = optionsPassedTo(DockerLib);
+ expect(options.ca).toBeUndefined();
+ expect(agentOf(options).options).toMatchObject({ ca: undefined, rejectUnauthorized: false });
+ });
+
+ it("passes explicit undefined for every field docker-modem would otherwise fill from the environment", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeTlsMonitor(), ctx);
+
+ const options = optionsPassedTo(DockerLib);
+ for (const field of ["socketPath", "username", "sshOptions"]) {
+ expect(options).toHaveProperty(field, undefined);
+ }
+ });
+
+ it("bounds every request with the provider timeout so a filtered port fails fast", async () => {
+ const { provider, DockerLib } = setup();
+
+ await provider.handle(makeTlsMonitor(), ctx);
+
+ expect(optionsPassedTo(DockerLib)).toMatchObject(TIMEOUTS);
+ });
+
+ it("returns a down check and never constructs a client when the key is missing from the context", async () => {
+ const { provider, DockerLib, encryptionService } = setup();
+
+ const result = await provider.handle(makeTlsMonitor());
+
+ expect(result).toMatchObject({ status: false, code: NETWORK_ERROR, message: "Docker TLS key is missing", payload: null });
+ expect(encryptionService.decrypt).not.toHaveBeenCalled();
+ expect(DockerLib).not.toHaveBeenCalled();
+ });
+
+ it("returns a down check naming the key id when the stored key cannot be decrypted", async () => {
+ const decrypt = jest.fn(() => {
+ throw new AppError({
+ message: "No encryption key matches ciphertext",
+ status: 500,
+ service: "EncryptionService",
+ method: "decrypt",
+ details: { keyId: "abc123" },
+ });
+ });
+ const { provider, DockerLib } = setup({ encryptionService: makeEncryptionService({ decrypt }) });
+
+ const result = await provider.handle(makeTlsMonitor(), ctx);
+
+ expect(result).toMatchObject({ status: false, code: NETWORK_ERROR, message: "No encryption key matches ciphertext (key id abc123)" });
+ expect(DockerLib).not.toHaveBeenCalled();
+ });
+
+ it("returns a generic down check when decryption fails with a non-AppError", async () => {
+ const decrypt = jest.fn(() => {
+ throw new Error("boom");
+ });
+ const { provider, DockerLib } = setup({ encryptionService: makeEncryptionService({ decrypt }) });
+
+ const result = await provider.handle(makeTlsMonitor(), ctx);
+
+ expect(result).toMatchObject({ status: false, message: "Docker TLS key could not be decrypted" });
+ expect(DockerLib).not.toHaveBeenCalled();
+ });
+
+ it("does not touch the encryption service for socket monitors", async () => {
+ const { provider, encryptionService } = setup();
+
+ await provider.handle(makeMonitor(), ctx);
+
+ expect(encryptionService.decrypt).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Fail-loudly url validation ───────────────────────────────────────
+
+ describe("invalid host urls", () => {
+ const invalidUrls: [label: string, url: string, message: string][] = [
+ ["empty url", "", "Docker host URL is required"],
+ ["unix:// with an empty path", "unix://", "Invalid Docker host URL"],
+ ["unix:// with a relative path", "unix://run/docker.sock", "Invalid Docker host URL"],
+ ["garbage", "not-a-url", "Invalid Docker host URL"],
+ ["a container name (old semantics)", "my-container", "Invalid Docker host URL"],
+ ["http engine urls (plaintext tcp is unsupported)", "http://host", "Invalid Docker host URL"],
+ ["a bare host and port", "host:2376", "Invalid Docker host URL"],
+ ["a tcp url with a path", "tcp://host/engine", "Invalid Docker host URL"],
+ ["ssh engine urls (unsupported)", "ssh://deploy@host", "Invalid Docker host URL"],
+ ];
+
+ it.each(invalidUrls)("throws AppError for %s and never constructs a client", async (_label, url, message) => {
+ const { provider, DockerLib } = setup();
+
+ await expect(provider.handle(makeMonitor({ url }))).rejects.toThrow(message);
+ expect(DockerLib).not.toHaveBeenCalled();
+ });
+
+ it("preserves the toDockerOptions AppError through handle's catch", async () => {
+ const { provider } = setup();
+
+ await expect(provider.handle(makeMonitor({ url: "not-a-url" }))).rejects.toMatchObject({
+ service: "DockerProvider",
+ method: "toDockerOptions",
+ status: 422,
+ });
+ await expect(provider.handle(makeMonitor({ url: "not-a-url" }))).rejects.toBeInstanceOf(AppError);
+ });
+ });
+
+ // ── Host reachability ────────────────────────────────────────────────
+
+ describe("host reachability", () => {
+ it("returns a down check with NETWORK_ERROR when ping rejects with a plain Error", async () => {
+ const { provider, listContainers } = setup({ ping: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")) });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.status).toBe(false);
+ expect(result.code).toBe(NETWORK_ERROR);
+ expect(result.message).toBe("ECONNREFUSED");
+ expect(result.payload).toBeNull();
+ expect(listContainers).not.toHaveBeenCalled();
+ });
+
+ it("uses the DockerError statusCode and json message when ping rejects with one", async () => {
+ const dockerErr = Object.assign(new Error("base"), { statusCode: 500, json: { message: "engine exploded" } });
+ const { provider } = setup({ ping: jest.fn().mockRejectedValue(dockerErr) });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.code).toBe(500);
+ expect(result.message).toBe("engine exploded");
+ });
+
+ it("falls back to the DockerError reason when json has no message", async () => {
+ const dockerErr = Object.assign(new Error("base"), { statusCode: 500, reason: "server error", json: {} });
+ const { provider } = setup({ ping: jest.fn().mockRejectedValue(dockerErr) });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.message).toBe("server error");
+ });
+
+ it("uses the default message when ping rejects with a non-Error", async () => {
+ const { provider } = setup({ ping: jest.fn().mockRejectedValue("nope") });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.status).toBe(false);
+ expect(result.message).toBe("Docker host is unreachable");
+ });
+ });
+
+ // ── Happy path ───────────────────────────────────────────────────────
+
+ describe("container payload", () => {
+ it("returns an up check with enriched containers and summary counts", async () => {
+ const { provider } = setup();
+
+ const result = await provider.handle(makeMonitor());
expect(result.status).toBe(true);
expect(result.code).toBe(200);
- });
-
- it("matches by exact full ID (64 chars)", async () => {
- const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
- const { provider } = createProvider();
-
- const result = await provider.handle(makeMonitor({ url: fullId }));
-
- expect(result.status).toBe(true);
- });
-
- it("matches by partial ID prefix", async () => {
- const { provider } = createProvider();
-
- const result = await provider.handle(makeMonitor({ url: "abc123" }));
-
- expect(result.status).toBe(true);
- });
-
- it("strips leading slashes from input", async () => {
- const { provider } = createProvider();
-
- const result = await provider.handle(makeMonitor({ url: "///my-container" }));
-
- expect(result.status).toBe(true);
- });
-
- it("matches case-insensitively", async () => {
- const { provider } = createProvider();
-
- const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" }));
-
- expect(result.status).toBe(true);
- });
-
- it("returns 404 when no container matches", async () => {
- const { provider, logger } = createProvider();
-
- const result = await provider.handle(makeMonitor({ url: "nonexistent" }));
-
- expect(result.status).toBe(false);
- expect(result.code).toBe(404);
- expect(result.message).toBe("Docker container not found");
- expect(logger.warn).toHaveBeenCalledWith(
+ expect(result.message).toBe("Docker host is reachable");
+ expect(result.payload?.summary).toEqual({ total: 1, running: 1, stopped: 0, unhealthy: 0 });
+ expect(result.payload?.containers[0]).toEqual(
expect.objectContaining({
- message: expect.stringContaining("No container found"),
- })
- );
- });
- });
-
- // ── Ambiguity detection ──────────────────────────────────────────────
-
- describe("ambiguity detection", () => {
- it("returns error when input matches both exact name and partial ID", async () => {
- // Container whose name matches the input AND whose ID starts with the input
- const container = makeContainer({
- Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
- Names: ["/my-container"],
- });
- const { provider, logger } = createProvider({ containers: [container] });
-
- const result = await provider.handle(makeMonitor({ url: "my-container" }));
-
- expect(result.status).toBe(false);
- expect(result.code).toBe(NETWORK_ERROR);
- expect(result.message).toContain("Ambiguous");
- expect(logger.warn).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining("Ambiguous"),
+ id: makeContainer().Id,
+ name: "my-container",
+ image: "nginx:latest",
+ state: "running",
+ status: "Up 3 hours (healthy)",
+ health: "healthy",
+ restartCount: 2,
+ startedAt: "2026-08-28T10:00:00.000Z",
})
);
});
- it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => {
- const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd";
- const container = makeContainer({
- Id: fullId,
- Names: ["/" + fullId],
- });
- const { provider } = createProvider({ containers: [container] });
+ it("counts running, stopped, and unhealthy states in the summary", async () => {
+ const containers = [
+ makeContainer({ Id: "a".repeat(64) }),
+ makeContainer({ Id: "b".repeat(64), State: "exited" }),
+ makeContainer({ Id: "c".repeat(64), State: "dead" }),
+ makeContainer({ Id: "d".repeat(64), State: "paused" }),
+ ];
+ const inspect = jest.fn().mockResolvedValue(makeInspect({ State: { Health: { Status: "unhealthy" } } }));
+ const { provider } = setup({ listContainers: jest.fn().mockResolvedValue(containers), inspect });
- const result = await provider.handle(makeMonitor({ url: fullId }));
+ const result = await provider.handle(makeMonitor());
- expect(result.message).toContain("Using exact ID");
+ expect(result.payload?.summary).toEqual({ total: 4, running: 1, stopped: 2, unhealthy: 4 });
});
- it("reports 'exact name' in ambiguity message when name and partial ID match", async () => {
- const container = makeContainer({
- Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd",
- Names: ["/my-container"],
- });
- const { provider } = createProvider({ containers: [container] });
+ it("falls back to a truncated id when a container has no name", async () => {
+ const container = makeContainer({ Names: [] });
+ const { provider } = setup({ listContainers: jest.fn().mockResolvedValue([container]) });
- const result = await provider.handle(makeMonitor({ url: "my-container" }));
+ const result = await provider.handle(makeMonitor());
- expect(result.message).toContain("Using exact name");
+ expect(result.payload?.containers[0]?.name).toBe(container.Id.slice(0, 12));
+ });
+
+ it("clamps unknown container states and health statuses", async () => {
+ const container = makeContainer({ State: "warp-speed" });
+ const inspect = jest.fn().mockResolvedValue(makeInspect({ State: { Health: { Status: "confused" } } }));
+ const { provider } = setup({ listContainers: jest.fn().mockResolvedValue([container]), inspect });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.state).toBe("created");
+ expect(result.payload?.containers[0]?.health).toBe("none");
});
});
- // ── Container inspection ─────────────────────────────────────────────
+ describe("container logs", () => {
+ it("parses multiplexed stdout and stderr with normalized timestamps", async () => {
+ const logs = jest
+ .fn()
+ .mockResolvedValue(
+ Buffer.concat([makeLogFrame(1, "2026-01-01T00:00:37.1Z ready\n"), makeLogFrame(2, "2026-01-01T00:00:38.123Z failed\r\n")])
+ );
+ const { provider } = setup({ logs });
- describe("container inspection", () => {
- it("returns running status for running container", async () => {
- const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } });
+ const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true }));
+
+ expect(result.payload?.logs?.[0]?.lines).toEqual([
+ { ts: "2026-01-01T00:00:37.100000000Z", stream: "stdout", text: "ready" },
+ { ts: "2026-01-01T00:00:38.123000000Z", stream: "stderr", text: "failed" },
+ ]);
+ });
+
+ it("treats raw TTY output as stdout and drops lines without timestamps", async () => {
+ const logs = jest.fn().mockResolvedValue(Buffer.from("noise\n2026-01-01T00:00:00Z first\n2026-01-01T00:00:01.12Z second\n"));
+ const { provider } = setup({ logs });
+
+ const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true }));
+
+ expect(result.payload?.logs?.[0]?.lines).toEqual([
+ { ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "first" },
+ { ts: "2026-01-01T00:00:01.120000000Z", stream: "stdout", text: "second" },
+ ]);
+ });
+
+ it("truncates oversized UTF-8 lines and appends the marker", async () => {
+ const logs = jest.fn().mockResolvedValue(Buffer.from(`2026-01-01T00:00:00Z ${"x".repeat(5000)}\n`));
+ const { provider } = setup({ logs });
+
+ const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true }));
+ const text = result.payload?.logs?.[0]?.lines[0]?.text;
+ expect(text).toEqual(expect.stringMatching(/ …\[truncated\]$/));
+ expect(Buffer.byteLength(text?.replace(" …[truncated]", "") ?? "", "utf8")).toBeLessThanOrEqual(4096);
+ });
+
+ it("isolates a logs failure and warns", async () => {
+ const { provider, logger } = setup({ logs: jest.fn().mockRejectedValue(new Error("logs failed")) });
+
+ const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true }));
+
+ expect(result.payload?.logs).toEqual([]);
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to read logs for container my-container" }));
+ });
+
+ it("requests a bounded timestamped tail for every container state", async () => {
+ const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })];
+ const { provider, logs } = setup({ listContainers: jest.fn().mockResolvedValue(containers) });
+
+ await provider.handle(makeMonitor({ dockerLogsEnabled: true }));
+
+ expect(logs).toHaveBeenCalledTimes(2);
+ expect(logs).toHaveBeenCalledWith({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES });
+ });
+
+ it("does not read logs when dockerLogsEnabled is false", async () => {
+ const { provider, logs } = setup();
+
+ const result = await provider.handle(makeMonitor({ dockerLogsEnabled: false }));
+
+ expect(logs).not.toHaveBeenCalled();
+ expect(result.payload?.logs).toBeUndefined();
+ });
+
+ it("does not read logs when dockerLogsEnabled is unset", async () => {
+ const { provider, logs } = setup();
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(logs).not.toHaveBeenCalled();
+ expect(result.payload?.logs).toBeUndefined();
+ });
+ });
+
+ // ── Enrichment fault isolation ───────────────────────────────────────
+
+ describe("enrichment fault isolation", () => {
+ it("keeps the container and the up status when inspect rejects", async () => {
+ const inspect = jest.fn().mockRejectedValue(new Error("inspect failed"));
+ const { provider, logger } = setup({ inspect });
const result = await provider.handle(makeMonitor());
expect(result.status).toBe(true);
- expect(result.message).toBe("Docker container status fetched successfully");
+ expect(result.payload?.containers).toHaveLength(1);
+ expect(result.payload?.containers[0]).toEqual(expect.objectContaining({ health: "none" }));
+ expect(result.payload?.containers[0]?.restartCount).toBeUndefined();
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Failed to inspect") }));
});
- it("returns not-running status for stopped container", async () => {
- const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } });
+ it("keeps the container without metrics when stats rejects", async () => {
+ const stats = jest.fn().mockRejectedValue(new Error("stats failed"));
+ const { provider } = setup({ stats });
const result = await provider.handle(makeMonitor());
- expect(result.status).toBe(false);
+ expect(result.status).toBe(true);
+ expect(result.payload?.containers[0]?.cpuPct).toBeUndefined();
+ expect(result.payload?.containers[0]?.memoryUsedBytes).toBeUndefined();
});
- it("returns NETWORK_ERROR when inspect response is null", async () => {
- const logger = createMockLogger();
- const inspect = jest.fn().mockResolvedValue(null);
- const getContainer = jest.fn().mockReturnValue({ inspect });
- const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer };
- const DockerLib = jest.fn().mockReturnValue(instance) as any;
- const provider = new DockerProvider(logger as any, DockerLib);
+ it("only requests stats for running containers", async () => {
+ const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })];
+ const { provider, stats } = setup({ listContainers: jest.fn().mockResolvedValue(containers) });
- const result = await provider.handle(makeMonitor());
+ await provider.handle(makeMonitor());
- expect(result.status).toBe(false);
- expect(result.code).toBe(NETWORK_ERROR);
- expect(result.message).toBe("No response from Docker container inspect");
- });
-
- it("returns false status when inspect response has no State", async () => {
- const { provider } = createProvider({ inspectResult: {} });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.status).toBe(false);
- });
-
- it("returns false status when inspect response has State but no Status", async () => {
- const { provider } = createProvider({ inspectResult: { State: {} } });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.status).toBe(false);
- });
-
- it("handles inspect error with Docker-specific error", async () => {
- const { provider } = createProvider();
- // Make inspect throw a Docker error
- const dockerErr = Object.assign(new Error("container not found"), {
- statusCode: 404,
- reason: "no such container",
- json: { message: "Container not found" },
- });
- provider["docker"] = {
- listContainers: jest.fn().mockResolvedValue([makeContainer()]),
- getContainer: jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue(dockerErr),
- }),
- } as any;
-
- // Need to re-assign; simpler to use the createProvider mock
- const { provider: p2 } = createProvider();
- // Override getContainer to return failing inspect
- p2["docker"].getContainer = jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue(dockerErr),
- });
-
- const result = await p2.handle(makeMonitor());
-
- expect(result.status).toBe(false);
- expect(result.code).toBe(404);
- expect(result.message).toBe("Container not found");
- });
-
- it("handles inspect error with Docker error using reason fallback", async () => {
- const { provider } = createProvider();
- const dockerErr = Object.assign(new Error("error"), {
- statusCode: 500,
- reason: "internal error",
- json: {},
- });
- provider["docker"].getContainer = jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue(dockerErr),
- });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.message).toBe("internal error");
- });
-
- it("handles inspect error with Docker error using Error message fallback", async () => {
- const { provider } = createProvider();
- const dockerErr = Object.assign(new Error("base error"), {
- statusCode: 503,
- reason: undefined,
- json: undefined,
- });
- provider["docker"].getContainer = jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue(dockerErr),
- });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.message).toBe("base error");
- });
-
- it("handles inspect error with NETWORK_ERROR when no statusCode", async () => {
- const { provider } = createProvider();
- const dockerErr = Object.assign(new Error("unknown"), {
- statusCode: undefined,
- });
- provider["docker"].getContainer = jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue(dockerErr),
- });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.code).toBe(NETWORK_ERROR);
- });
-
- it("handles inspect error with standard Error (not Docker error)", async () => {
- const { provider } = createProvider();
- provider["docker"].getContainer = jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")),
- });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.status).toBe(false);
- expect(result.message).toBe("ECONNREFUSED");
- });
-
- it("handles inspect error with non-Error non-Docker thrown value", async () => {
- const { provider } = createProvider();
- provider["docker"].getContainer = jest.fn().mockReturnValue({
- inspect: jest.fn().mockRejectedValue("string error"),
- });
-
- const result = await provider.handle(makeMonitor());
-
- expect(result.status).toBe(false);
- expect(result.message).toBe("Failed to fetch Docker container information");
+ expect(stats).toHaveBeenCalledTimes(1);
});
});
- // ── Outer catch ──────────────────────────────────────────────────────
+ // ── Metric math ──────────────────────────────────────────────────────
+
+ describe("metric math", () => {
+ it("computes cpu and memory percentages from cgroup v2 stats", async () => {
+ const { provider } = setup();
+
+ const result = await provider.handle(makeMonitor());
+ const container = result.payload?.containers[0];
+
+ expect(container?.cpuPct).toBeCloseTo(0.8);
+ expect(container?.memoryUsedBytes).toBe(200 * 1024 * 1024);
+ expect(container?.memoryLimitBytes).toBe(1024 * 1024 * 1024);
+ expect(container?.memoryPct).toBeCloseTo(0.1953125);
+ });
+
+ it("falls back to the cgroup v1 cache field for page cache", async () => {
+ const stats = jest
+ .fn()
+ .mockResolvedValue(makeStats({ memory_stats: { usage: 300 * 1024 * 1024, limit: 1024 * 1024 * 1024, stats: { cache: 50 * 1024 * 1024 } } }));
+ const { provider } = setup({ stats });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.memoryUsedBytes).toBe(250 * 1024 * 1024);
+ });
+
+ it("derives cpu count from percpu_usage when online_cpus is absent", async () => {
+ const stats = jest.fn().mockResolvedValue(
+ makeStats({
+ cpu_stats: { cpu_usage: { total_usage: 400_000_000, percpu_usage: [1, 2] }, system_cpu_usage: 2_000_000_000 },
+ })
+ );
+ const { provider } = setup({ stats });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(0.4);
+ });
+
+ it("returns 0 cpu when the system delta is not positive", async () => {
+ const stats = jest
+ .fn()
+ .mockResolvedValue(makeStats({ precpu_stats: { cpu_usage: { total_usage: 200_000_000 }, system_cpu_usage: 2_000_000_000 } }));
+ const { provider } = setup({ stats });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.cpuPct).toBe(0);
+ });
+
+ it("returns 0 memory percent when there is no limit", async () => {
+ const stats = jest.fn().mockResolvedValue(makeStats({ memory_stats: { usage: 300 * 1024 * 1024, limit: 0, stats: {} } }));
+ const { provider } = setup({ stats });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.memoryPct).toBe(0);
+ });
+ });
+
+ // ── Ports and mounts ─────────────────────────────────────────────────
+
+ describe("ports and mounts", () => {
+ it("maps published and unpublished ports and mounts from inspect", async () => {
+ const inspect = jest.fn().mockResolvedValue(
+ makeInspect({
+ NetworkSettings: {
+ Ports: {
+ "80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }],
+ "443/tcp": null,
+ },
+ },
+ Mounts: [
+ {
+ Type: "volume",
+ Name: "mongo_data",
+ Source: "/var/lib/docker/volumes/mongo_data/_data",
+ Destination: "/data/db",
+ Mode: "z",
+ RW: true,
+ },
+ { Type: "bind", Source: "/srv/config", Destination: "/etc/app", Mode: "ro", RW: false },
+ ],
+ })
+ );
+ const { provider } = setup({ inspect });
+
+ const result = await provider.handle(makeMonitor());
+
+ const container = result.payload?.containers[0];
+ expect(container?.ports).toEqual([
+ { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" },
+ { privatePort: 443, protocol: "tcp" },
+ ]);
+ expect(container?.mounts).toEqual([
+ { type: "volume", name: "mongo_data", source: "/var/lib/docker/volumes/mongo_data/_data", destination: "/data/db", mode: "z", rw: true },
+ { type: "bind", name: undefined, source: "/srv/config", destination: "/etc/app", mode: "ro", rw: false },
+ ]);
+ });
+
+ it("emits one port entry per host binding", async () => {
+ const inspect = jest.fn().mockResolvedValue(
+ makeInspect({
+ NetworkSettings: {
+ Ports: {
+ "80/tcp": [
+ { HostIp: "0.0.0.0", HostPort: "8080" },
+ { HostIp: "::", HostPort: "8080" },
+ ],
+ },
+ },
+ })
+ );
+ const { provider } = setup({ inspect });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.ports).toEqual([
+ { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" },
+ { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "::" },
+ ]);
+ });
+
+ it("maps missing ports and mounts to empty arrays", async () => {
+ const { provider } = setup();
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.ports).toEqual([]);
+ expect(result.payload?.containers[0]?.mounts).toEqual([]);
+ });
+
+ it("leaves ports and mounts undefined when inspect fails", async () => {
+ const inspect = jest.fn().mockRejectedValue(new Error("inspect failed"));
+ const { provider } = setup({ inspect });
+
+ const result = await provider.handle(makeMonitor());
+
+ expect(result.payload?.containers[0]?.ports).toBeUndefined();
+ expect(result.payload?.containers[0]?.mounts).toBeUndefined();
+ });
+ });
+
+ // ── Outer error handling ─────────────────────────────────────────────
describe("outer error handling", () => {
- it("throws AppError when containerInput is missing", async () => {
- const { provider } = createProvider();
-
- await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor");
- });
-
- it("throws AppError when listContainers throws", async () => {
- const { provider } = createProvider();
- provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable"));
+ it("throws AppError when listContainers rejects after a successful ping", async () => {
+ const { provider } = setup({ listContainers: jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")) });
await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable");
});
- it("throws AppError with default message for non-Error thrown values", async () => {
- const { provider } = createProvider();
- provider["docker"].listContainers = jest.fn().mockRejectedValue(null);
+ it("throws AppError with the default message for non-Error thrown values", async () => {
+ const { provider } = setup({ listContainers: jest.fn().mockRejectedValue(null) });
await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request");
});
diff --git a/server/test/unit/providers/notifications/signalgridProvider.test.ts b/server/test/unit/providers/notifications/signalgridProvider.test.ts
new file mode 100644
index 000000000..8c40bb1d9
--- /dev/null
+++ b/server/test/unit/providers/notifications/signalgridProvider.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, it, jest, beforeEach } from "@jest/globals";
+import { createMockLogger } from "../../../helpers/createMockLogger.ts";
+import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
+import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
+
+const mockGotPost = jest.fn().mockResolvedValue({});
+jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
+
+const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts");
+
+const createProvider = () => {
+ const logger = createMockLogger();
+ return { provider: new SignalgridProvider(logger as any), logger };
+};
+
+testNotificationProviderContract("SignalgridProvider", {
+ create: () => {
+ mockGotPost.mockResolvedValue({});
+ return createProvider().provider;
+ },
+ makeNotification: () => makeNotification(),
+});
+
+describe("SignalgridProvider", () => {
+ beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
+
+ describe("sendTestAlert", () => {
+ it("sends to Signalgrid API and returns true", async () => {
+ expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true);
+ expect(mockGotPost).toHaveBeenCalledWith(
+ "https://api.signalgrid.co/v1/push",
+ expect.objectContaining({
+ form: expect.objectContaining({
+ client_key: "token-abc",
+ channel: "https://hooks.example.com/webhook",
+ type: "INFO",
+ critical: false,
+ }),
+ })
+ );
+ });
+
+ it("returns false when address is missing", async () => {
+ expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false);
+ });
+
+ it("returns false when accessToken is missing", async () => {
+ expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false);
+ });
+
+ it("returns false and logs on error", async () => {
+ mockGotPost.mockRejectedValue(new Error("fail"));
+ const { provider, logger } = createProvider();
+ expect(await provider.sendTestAlert(makeNotification())).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
+ });
+
+ it("handles non-Error thrown values in sendTestAlert", async () => {
+ mockGotPost.mockRejectedValue("string error");
+ const { provider, logger } = createProvider();
+ expect(await provider.sendTestAlert(makeNotification())).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
+ });
+ });
+
+ describe("sendMessage", () => {
+ it("sends message and returns true", async () => {
+ const { provider } = createProvider();
+ expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true);
+ const form = mockGotPost.mock.calls[0][1].form;
+ expect(form.body).toContain("Monitor Details:");
+ expect(form.title).toBeDefined();
+ expect(form.critical).toBe(false);
+ });
+
+ it("maps critical severity to CRIT", async () => {
+ const { provider } = createProvider();
+ const message = makeMessage();
+ message.severity = "critical";
+ await provider.sendMessage(makeNotification() as any, message);
+ expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT");
+ });
+
+ it("maps warning severity to WARN", async () => {
+ const { provider } = createProvider();
+ const message = makeMessage();
+ message.severity = "warning";
+ await provider.sendMessage(makeNotification() as any, message);
+ expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN");
+ });
+
+ it("maps success severity to SUCCESS", async () => {
+ const { provider } = createProvider();
+ const message = makeMessage();
+ message.severity = "success";
+ await provider.sendMessage(makeNotification() as any, message);
+ expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS");
+ });
+
+ it("maps info severity to INFO", async () => {
+ const { provider } = createProvider();
+ const message = makeMessage();
+ message.severity = "info";
+ await provider.sendMessage(makeNotification() as any, message);
+ expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO");
+ });
+
+ it("returns false when address is missing", async () => {
+ expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false);
+ });
+
+ it("returns false when accessToken is missing", async () => {
+ expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
+ });
+
+ it("returns false and logs on error", async () => {
+ mockGotPost.mockRejectedValue(new Error("fail"));
+ const { provider, logger } = createProvider();
+ expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
+ });
+
+ it("handles non-Error thrown values in sendMessage", async () => {
+ mockGotPost.mockRejectedValue(42);
+ const { provider, logger } = createProvider();
+ expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
+ });
+
+ it("includes thresholds in text", async () => {
+ const { provider } = createProvider();
+ await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds());
+ expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU");
+ });
+
+ it("includes incident link in text", async () => {
+ const { provider } = createProvider();
+ await provider.sendMessage(makeNotification() as any, makeMessageWithIncident());
+ expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident");
+ });
+
+ it("omits optional sections when not present", async () => {
+ const { provider } = createProvider();
+ const msg = makeMessage();
+ msg.content.thresholds = undefined;
+ msg.content.details = undefined;
+ msg.content.incident = undefined;
+ await provider.sendMessage(makeNotification() as any, msg);
+ const text = mockGotPost.mock.calls[0][1].form.body;
+ expect(text).not.toContain("Threshold");
+ expect(text).not.toContain("Additional");
+ expect(text).not.toContain("View Incident");
+ });
+ });
+});
diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts
index 90b5489bf..d193c7b3d 100644
--- a/server/test/unit/services/bufferService.test.ts
+++ b/server/test/unit/services/bufferService.test.ts
@@ -7,6 +7,8 @@ import type { ISettingsService } from "../../../src/domain/app-settings/app-sett
import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts";
import type { Check } from "../../../src/domain/checks/check.type.ts";
import type { GeoCheck } from "../../../src/domain/geo-checks/geo-check.type.ts";
+import type { IDockerLogsService } from "../../../src/domain/docker/docker-log.service.ts";
+import type { DockerLog } from "../../../src/domain/docker/docker-log.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -20,6 +22,11 @@ const createMockGeoChecksService = () =>
createGeoChecks: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked