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 @@ [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) - -image +image


@@ -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 ![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) -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: ![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) @@ -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)} > - + {isAdmin && ( + + )} + + + )} + {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 = ({ + )} + + )} + {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 && ( - - - - - )} - { - 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) => ( + - } - /> - - - - - - - } - /> - {isAdmin && ( - 0 ? ( - - - {globalProxyEnabled && ( - - )} - - ) : ( - - ) - } - /> - )} - {isAdmin && ( - - {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( - - )} + ))} + + )} - {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - - {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} - - - - )} - - } - /> - )} - - {/* 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 && ( - - - - - + + + + + } /> - )} - - - {/* 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")} + + + + + + ) : ( + + )} + + {/* TLS Servername (Optional) */} + + + {/* Connection Host (Optional) */} + + + {/* Boolean Switches */} + + {emailSwitches.map(({ name, label }) => ( + + ))} + + + {/* Test Email Button */} + + + + } + /> + )} + + )} + + {/* 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")} + + + + )} + + } + /> + )} + + )} + + {/* data tab */} + {activeTab === "data" && ( + + {/* Export Monitors - Admin Only */} + {isAdmin && ( + + + + + + } + /> + )} + {/* Clear All Stats */} + {isAdmin && ( + setIsStatsDialogOpen(true)} > - {t("common.buttons.sendTestEmail")} + {t("common.buttons.clear")} - - - } - /> - )} + } + /> + )} - {/* Demo Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> + {/* Remove All Monitors - Admin Only */} + {isAdmin && ( + + + + } + /> + )} + )} - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Export Monitors - Admin Only */} - {isAdmin && ( - - - - - - } - /> - )} - - {/* 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 = ({ - - - )} - { - 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; +const createMockDockerLogsService = () => + ({ + createDockerLogs: jest.fn().mockResolvedValue(0), + }) as unknown as jest.Mocked; + const createMockSettingsService = (nodeEnv: string = "development") => ({ getSettings: jest.fn().mockReturnValue({ nodeEnv }), @@ -48,14 +55,28 @@ const makeGeoCheck = (overrides?: Partial): GeoCheck => ...overrides, }) as GeoCheck; +const makeDockerLog = (overrides?: Partial): DockerLog => + ({ + id: "docker-log-1", + metadata: { monitorId: "mon-1", teamId: "team-1", containerId: "container-1", containerName: "web" }, + lines: [{ ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "ready" }], + gap: false, + checkedAt: "2026-01-01T00:00:01.000Z", + expiry: "2026-01-08T00:00:01.000Z", + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + ...overrides, + }) as DockerLog; + const createService = (nodeEnv: string = "development") => { const logger = createMockLogger(); const checkService = createMockCheckService(); const geoChecksService = createMockGeoChecksService(); + const dockerLogsService = createMockDockerLogsService(); const settingsService = createMockSettingsService(nodeEnv); const jobsRepository = createMockJobsRepository(); - const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository); - return { service, logger, checkService, geoChecksService, jobsRepository }; + const service = new BufferService(logger as any, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository); + return { service, logger, checkService, geoChecksService, dockerLogsService, jobsRepository }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -209,6 +230,27 @@ describe("BufferService", () => { }); }); + describe("addDockerLogToBuffer", () => { + it("adds a docker log to the buffer", async () => { + const { service, dockerLogsService } = createService(); + const dockerLog = makeDockerLog(); + + service.addDockerLogToBuffer(dockerLog); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); + }); + + it("logs an error if push throws", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "dockerLogBuffer", { value: Object.freeze([]) }); + + service.addDockerLogToBuffer(makeDockerLog()); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "addDockerLogToBuffer" })); + }); + }); + // ── scheduleNextFlush ──────────────────────────────────────────────── describe("scheduleNextFlush", () => { @@ -223,15 +265,17 @@ describe("BufferService", () => { expect(jest.getTimerCount()).toBe(initialTimerCount); }); - it("flushes buffer and geo buffer when timer fires", async () => { - const { service, checkService, geoChecksService } = createService(); + it("flushes all buffers when timer fires", async () => { + const { service, checkService, geoChecksService, dockerLogsService } = createService(); service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); + service.addDockerLogToBuffer(makeDockerLog()); await jest.advanceTimersByTimeAsync(1000); expect(checkService.createChecks).toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).toHaveBeenCalled(); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalled(); }); it("reschedules after flush completes", async () => { @@ -472,18 +516,72 @@ describe("BufferService", () => { }); }); + describe("flushDockerLogsBuffer", () => { + it("does nothing when the docker log buffer is empty", async () => { + const { service, dockerLogsService } = createService(); + + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled(); + }); + + it("flushes the batch and clears it", async () => { + const { service, dockerLogsService } = createService(); + const dockerLog = makeDockerLog(); + service.addDockerLogToBuffer(dockerLog); + + await service.flushDockerLogsBuffer(); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); + }); + + it("does not drop logs added while a flush is in flight", async () => { + const { service, dockerLogsService } = createService(); + let resolveWrite!: (value: number) => void; + (dockerLogsService.createDockerLogs as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => (resolveWrite = resolve))); + const first = makeDockerLog({ id: "docker-log-1" }); + const second = makeDockerLog({ id: "docker-log-2" }); + service.addDockerLogToBuffer(first); + + const flush = service.flushDockerLogsBuffer(); + service.addDockerLogToBuffer(second); + resolveWrite(1); + await flush; + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(1, [first]); + expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(2, [second]); + }); + + it("drops a failed batch and logs the error", async () => { + const { service, dockerLogsService, logger } = createService(); + (dockerLogsService.createDockerLogs as jest.Mock).mockRejectedValueOnce(new Error("DB error")); + service.addDockerLogToBuffer(makeDockerLog()); + + await service.flushDockerLogsBuffer(); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "DB error", method: "flushDockerLogsBuffer" })); + }); + }); + // ── shutdown ───────────────────────────────────────────────────────────── describe("shutdown", () => { - it("stops the flush timer and flushes both buffers", async () => { - const { service, checkService, geoChecksService } = createService(); + it("stops the flush timer and flushes all buffers", async () => { + const { service, checkService, geoChecksService, dockerLogsService } = createService(); service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); + service.addDockerLogToBuffer(makeDockerLog()); await service.shutdown(); expect(checkService.createChecks).toHaveBeenCalledTimes(1); expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); // Timer is cleared: advancing past the flush interval triggers no further flush. await jest.advanceTimersByTimeAsync(60 * 1000); @@ -491,12 +589,13 @@ describe("BufferService", () => { }); it("is safe to call with empty buffers", async () => { - const { service, checkService, geoChecksService } = createService(); + const { service, checkService, geoChecksService, dockerLogsService } = createService(); await service.shutdown(); expect(checkService.createChecks).not.toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled(); + expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled(); }); }); }); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index 2a1aa29f1..c10cc2f29 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -22,12 +22,13 @@ const activeWindow = () => { const createProducer = (overrides?: Record) => { const defaults = { logger: createMockLogger(), - monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) }, + monitorsRepository: { updateById: jest.fn().mockResolvedValue({}), findDockerTlsKeyById: jest.fn().mockResolvedValue(null) }, maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) }, buffer: { addToBuffer: jest.fn() }, + dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue([]) }, ...overrides, }; const producer = new CheckProducer( @@ -37,6 +38,7 @@ const createProducer = (overrides?: Record) => { defaults.networkService as any, defaults.proxyResolver as any, defaults.buffer as any, + defaults.dockerLogsService as any, defaults.logger as any ); return { producer, defaults }; @@ -109,6 +111,48 @@ describe("CheckProducer", () => { // ── acquire / record ────────────────────────────────────────────────────── + // ── docker tls key resolution ───────────────────────────────────────────── + + it("fetches the stored key for a docker monitor with a key set and passes it in the context", async () => { + const { producer, defaults } = createProducer({ + monitorsRepository: { updateById: jest.fn(), findDockerTlsKeyById: jest.fn().mockResolvedValue("v1.abc123.iv.tag.data") }, + }); + const monitor = makeMonitor({ type: "docker", url: "tcp://host", dockerTlsKeySet: true }); + + await producer.produce(monitor); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1"); + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: "v1.abc123.iv.tag.data" }); + }); + + it("does not fetch a key for a docker monitor without one set", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKeySet: false }); + + await producer.produce(monitor); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled(); + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined }); + }); + + it("does not fetch a key for non-docker monitors even if the flag is set", async () => { + const { producer, defaults } = createProducer(); + + await producer.produce(makeMonitor({ type: "http", dockerTlsKeySet: true })); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).not.toHaveBeenCalled(); + }); + + it("passes undefined when the flag is set but the repository has no key", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor({ type: "docker", url: "tcp://host", dockerTlsKeySet: true }); + + await producer.produce(monitor); + + expect(defaults.monitorsRepository.findDockerTlsKeyById).toHaveBeenCalledWith("m1"); + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined, dockerTlsKey: undefined }); + }); + it("throws when the network response is null", async () => { const { producer } = createProducer({ networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, @@ -142,4 +186,28 @@ describe("CheckProducer", () => { expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith(check); expect(result).toEqual({ status, check }); }); + + it("builds and buffers docker logs for a docker status", async () => { + const status = { type: "docker", monitorId: "m1", teamId: "team", status: true, code: 200, message: "OK", payload: {} }; + const dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }]; + const { producer, defaults } = createProducer({ + networkService: { requestStatus: jest.fn().mockResolvedValue(status) }, + buffer: { addToBuffer: jest.fn(), addDockerLogToBuffer: jest.fn() }, + dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue(dockerLogs) }, + }); + + await producer.produce(makeMonitor({ type: "docker" })); + + expect(defaults.dockerLogsService.buildDockerLogs).toHaveBeenCalledWith(status); + expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(1, dockerLogs[0]); + expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(2, dockerLogs[1]); + }); + + it("does not build docker logs for a non-docker status", async () => { + const { producer, defaults } = createProducer(); + + await producer.produce(makeMonitor()); + + expect(defaults.dockerLogsService.buildDockerLogs).not.toHaveBeenCalled(); + }); }); diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts index 80b941b6f..4ced65947 100644 --- a/server/test/unit/services/checkService.test.ts +++ b/server/test/unit/services/checkService.test.ts @@ -252,6 +252,33 @@ describe("CheckService", () => { expect(check!.errors).toBeUndefined(); }); }); + + describe("docker type", () => { + it("extracts containers and containerSummary from payload", () => { + const payload = { + containers: [ + { id: "abc", name: "web", image: "nginx:latest", state: "running", status: "Up 3 hours", health: "healthy" }, + { id: "def", name: "db", image: "mongo:8.0", state: "exited", status: "Exited (0)", health: "none" }, + ], + summary: { total: 2, running: 1, stopped: 1, unhealthy: 0 }, + } as any; + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload } as any)); + + expect(check!.containers).toHaveLength(2); + expect(check!.containers![0]).toMatchObject({ id: "abc", state: "running", health: "healthy" }); + expect(check!.containerSummary).toEqual({ total: 2, running: 1, stopped: 1, unhealthy: 0 }); + }); + + it("handles a null payload gracefully (down check)", () => { + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload: null } as any)); + + expect(check).toBeDefined(); + expect(check!.containers).toBeUndefined(); + expect(check!.containerSummary).toBeUndefined(); + }); + }); }); // ── toStatusResponse round-trip (Phase 2 §5 guard) ───────────────────────── diff --git a/server/test/unit/services/dockerLogsService.test.ts b/server/test/unit/services/dockerLogsService.test.ts new file mode 100644 index 000000000..9e27a84e1 --- /dev/null +++ b/server/test/unit/services/dockerLogsService.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { DockerLogsService } from "../../../src/domain/docker/docker-log.service.ts"; +import { DOCKER_LOG_TAIL_LINES, type DockerLogLine } from "../../../src/domain/docker/docker.type.ts"; +import type { MonitorStatusResponse, DockerStatusPayload } from "../../../src/types/network.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const line = (second: number): DockerLogLine => ({ + ts: `2026-01-01T00:00:${String(second).padStart(2, "0")}.000000000Z`, + stream: "stdout", + text: `line ${second}`, +}); + +const status = (logs?: DockerStatusPayload["logs"]): MonitorStatusResponse => ({ + monitorId: "monitor-1", + teamId: "team-1", + type: "docker", + status: true, + code: 200, + message: "OK", + payload: { containers: [], summary: { total: 0, running: 0, stopped: 0, unhealthy: 0 }, logs }, +}); + +const createService = () => { + const repository = { + findLastLineTimestamp: jest.fn().mockResolvedValue(null), + createDockerLogs: jest.fn().mockResolvedValue(0), + }; + const logger = createMockLogger(); + return { service: new DockerLogsService({ logger: logger as any, dockerLogsRepository: repository as any }), repository, logger }; +}; + +describe("DockerLogsService", () => { + it("returns no documents for missing or empty logs", async () => { + const { service, repository } = createService(); + expect(await service.buildDockerLogs(status())).toEqual([]); + expect(await service.buildDockerLogs(status([]))).toEqual([]); + expect(repository.findLastLineTimestamp).not.toHaveBeenCalled(); + }); + + it("seeds the cursor, drops old lines, and sets seven-day expiry", async () => { + const { service, repository } = createService(); + repository.findLastLineTimestamp.mockResolvedValue(line(1).ts); + + const result = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines: [line(1), line(2)] }])); + + expect(repository.findLastLineTimestamp).toHaveBeenCalledWith("monitor-1", "container-1"); + expect(result[0]?.lines).toEqual([line(2)]); + expect(new Date(result[0]!.expiry).getTime() - new Date(result[0]!.checkedAt).getTime()).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it("advances the cursor in memory and does not query again", async () => { + const { service, repository } = createService(); + const logs = [{ containerId: "container-1", containerName: "web", lines: [line(1)] }]; + + expect(await service.buildDockerLogs(status(logs))).toHaveLength(1); + expect(await service.buildDockerLogs(status(logs))).toEqual([]); + expect(repository.findLastLineTimestamp).toHaveBeenCalledTimes(1); + }); + + it("sets gap only when every tail line is fresh", async () => { + const { service } = createService(); + const lines = Array.from({ length: DOCKER_LOG_TAIL_LINES }, (_, index) => ({ + ts: `2026-01-01T00:00:00.${String(index).padStart(9, "0")}Z`, + stream: "stdout" as const, + text: String(index), + })); + + const [result] = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines }])); + expect(result?.gap).toBe(true); + }); + + it("skips a failed container and continues with the others", async () => { + const { service, repository, logger } = createService(); + repository.findLastLineTimestamp.mockRejectedValueOnce(new Error("DB down")).mockResolvedValueOnce(null); + + const result = await service.buildDockerLogs( + status([ + { containerId: "bad", containerName: "bad-container", lines: [line(1)] }, + { containerId: "good", containerName: "good-container", lines: [line(2)] }, + ]) + ); + + expect(result).toHaveLength(1); + expect(result[0]?.metadata.containerId).toBe("good"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build docker logs for container bad-container" })); + }); +}); diff --git a/server/test/unit/services/encryptionService.test.ts b/server/test/unit/services/encryptionService.test.ts new file mode 100644 index 000000000..9797f4939 --- /dev/null +++ b/server/test/unit/services/encryptionService.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "@jest/globals"; +import * as nodeCrypto from "node:crypto"; +import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import type { EncryptionCryptoLib } from "../../../src/service/encryption/encryptionService.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const newKey = () => nodeCrypto.randomBytes(32); + +const setup = (keys: Buffer[], cryptoLib: EncryptionCryptoLib = nodeCrypto) => { + const logger = createMockLogger(); + const service = new EncryptionService(keys, logger, cryptoLib); + return { service, logger }; +}; + +const expectAppError = (fn: () => unknown, message: string, details?: Record) => { + let caught: unknown; + try { + fn(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AppError); + const appError = caught as AppError; + expect(appError.message).toBe(message); + expect(appError.status).toBe(500); + expect(appError.service).toBe("EncryptionService"); + if (details) expect(appError.details).toEqual(details); +}; + +const tamper = (ciphertext: string, segment: number, replacer: (value: string) => string) => { + const parts = ciphertext.split("."); + parts[segment] = replacer(parts[segment] ?? ""); + return parts.join("."); +}; + +const flipFirstChar = (value: string) => (value[0] === "A" ? "B" : "A") + value.slice(1); + +describe("EncryptionService", () => { + describe("construction", () => { + it("is unconfigured with no keys and says so", () => { + const { service, logger } = setup([]); + expect(service.isConfigured()).toBe(false); + expectAppError(() => service.currentKeyId(), "ENCRYPTION_KEY is not configured"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "ENCRYPTION_KEY not set; encrypted fields are unavailable" })); + }); + + it("is configured with one key and exposes a 6-character base64url key id", () => { + const { service, logger } = setup([newKey()]); + expect(service.isConfigured()).toBe(true); + expect(service.currentKeyId()).toMatch(/^[A-Za-z0-9_-]{6}$/); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Encryption configured with 1 key(s)") })); + }); + + it("rejects an entry that is not 32 bytes, naming its index", () => { + const short = nodeCrypto.randomBytes(31); + const long = nodeCrypto.randomBytes(33); + expectAppError(() => setup([newKey(), short]), "ENCRYPTION_KEY is not 32 bytes", { keyIdx: 1 }); + expectAppError(() => setup([long]), "ENCRYPTION_KEY is not 32 bytes", { keyIdx: 0 }); + }); + + it("rejects the same key listed twice", () => { + const key = newKey(); + expectAppError(() => setup([key, key]), "ENCRYPTION_KEY key ids collide; regenerate one key", { index: 1 }); + }); + + it("treats the first key as current", () => { + const a = newKey(); + const b = newKey(); + const { service: onlyA } = setup([a]); + const { service: onlyB } = setup([b]); + const { service: bThenA } = setup([b, a]); + expect(bThenA.currentKeyId()).toBe(onlyB.currentKeyId()); + expect(bThenA.currentKeyId()).not.toBe(onlyA.currentKeyId()); + }); + }); + + describe("round trip", () => { + it.each([ + ["ascii", "hello"], + ["pem-shaped", "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7\n-----END PRIVATE KEY-----\n"], + ["multi-byte utf-8", "clé privée — 秘密鍵 🔑"], + ["empty", ""], + ])("decrypts what it encrypted (%s)", (_label, plaintext) => { + const { service } = setup([newKey()]); + expect(service.decrypt(service.encrypt(plaintext))).toBe(plaintext); + }); + + it("uses a fresh IV per call so equal plaintexts encrypt differently", () => { + const { service } = setup([newKey()]); + const first = service.encrypt("same"); + const second = service.encrypt("same"); + expect(first).not.toBe(second); + expect(service.decrypt(first)).toBe("same"); + expect(service.decrypt(second)).toBe("same"); + }); + + it("produces the documented five-segment base64url form", () => { + const { service } = setup([newKey()]); + expect(service.encrypt("x")).toMatch(/^v1\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]*$/); + }); + + it("does not leak the plaintext into the stored form", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("-----BEGIN PRIVATE KEY-----"); + expect(ciphertext).not.toContain("PRIVATE"); + expect(Buffer.from(ciphertext.split(".")[4] ?? "", "base64url").toString("utf8")).not.toContain("PRIVATE"); + }); + + it("is deterministic for a fixed IV, pinning the format", () => { + const fixedIv = Buffer.alloc(12, 7); + const cryptoLib: EncryptionCryptoLib = { + createCipheriv: nodeCrypto.createCipheriv, + createDecipheriv: nodeCrypto.createDecipheriv, + createHash: nodeCrypto.createHash, + randomBytes: () => fixedIv, + }; + const key = newKey(); + const { service } = setup([key], cryptoLib); + const first = service.encrypt("pinned"); + const second = service.encrypt("pinned"); + expect(first).toBe(second); + expect(first.split(".")[2]).toBe(fixedIv.toString("base64url")); + expect(setup([key]).service.decrypt(first)).toBe("pinned"); + }); + }); + + describe("key selection and rotation", () => { + it("decrypts with a key that is not first in the list and reports it needs re-encryption", () => { + const a = newKey(); + const b = newKey(); + const { service: withA } = setup([a]); + const ciphertext = withA.encrypt("secret"); + + const { service: withBThenA } = setup([b, a]); + expect(withBThenA.decrypt(ciphertext)).toBe("secret"); + expect(withBThenA.keyIdOf(ciphertext)).toBe(withA.currentKeyId()); + expect(withBThenA.needsReencryption(ciphertext)).toBe(true); + }); + + it("encrypts under the first key and does not flag it for re-encryption", () => { + const { service } = setup([newKey(), newKey()]); + const ciphertext = service.encrypt("secret"); + expect(service.keyIdOf(ciphertext)).toBe(service.currentKeyId()); + expect(service.needsReencryption(ciphertext)).toBe(false); + }); + + it("fails with the key id when no listed key matches, but keyIdOf still answers", () => { + const a = newKey(); + const { service: withA } = setup([a]); + const ciphertext = withA.encrypt("secret"); + const idOfA = withA.currentKeyId(); + + const { service: withB } = setup([newKey()]); + expectAppError(() => withB.decrypt(ciphertext), "No encryption key matches ciphertext", { keyId: idOfA }); + expect(withB.keyIdOf(ciphertext)).toBe(idOfA); + }); + }); + + describe("tampering and malformed input", () => { + it("rejects a modified data segment", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("payload"); + expectAppError(() => service.decrypt(tamper(ciphertext, 4, flipFirstChar)), "Ciphertext failed authentication"); + }); + + it("rejects a modified tag segment", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("payload"); + expectAppError(() => service.decrypt(tamper(ciphertext, 3, flipFirstChar)), "Ciphertext failed authentication"); + }); + + it("rejects a modified iv segment", () => { + const { service } = setup([newKey()]); + const ciphertext = service.encrypt("payload"); + expectAppError(() => service.decrypt(tamper(ciphertext, 2, flipFirstChar)), "Ciphertext failed authentication"); + }); + + it("rejects a ciphertext relabelled with another listed key's id", () => { + const a = newKey(); + const b = newKey(); + const { service } = setup([a, b]); + const ciphertext = service.encrypt("payload"); + const idOfB = setup([b]).service.currentKeyId(); + expectAppError(() => service.decrypt(tamper(ciphertext, 1, () => idOfB)), "Ciphertext failed authentication"); + }); + + it("rejects a ciphertext whose key id matches a listed key made from different bytes", () => { + const a = newKey(); + const b = newKey(); + const ciphertext = setup([a]).service.encrypt("payload"); + const { service: withB } = setup([b]); + const forged = tamper(ciphertext, 1, () => withB.currentKeyId()); + expectAppError(() => withB.decrypt(forged), "Ciphertext failed authentication"); + }); + + it.each([ + ["too few segments", "v1.abc.def"], + ["too many segments", "v1.a.b.c.d.e"], + ["empty key id", "v1..AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["standard base64 padding", "v1.abcdef.AAAAAAAAAAAAAAA=.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["plus sign in a segment", "v1.abcdef.AAAAAAAAAAAAAA+A.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["iv of the wrong length", "v1.abcdef.AAAA.AAAAAAAAAAAAAAAAAAAAAA.AA"], + ["tag of the wrong length", "v1.abcdef.AAAAAAAAAAAAAAAA.AAAA.AA"], + ])("rejects malformed v1 input (%s)", (_label, input) => { + const { service } = setup([newKey()]); + expectAppError(() => service.decrypt(input), "Ciphertext is malformed"); + expectAppError(() => service.keyIdOf(input), "Ciphertext is malformed"); + }); + + it.each([ + ["empty string", "", ""], + ["no version segment", "abcdef.AAAA.AAAA.AA", "abcdef"], + ["future version with a different layout", "v2.x.y.z.w.v.u", "v2"], + ])("reports the version before any layout rule (%s)", (_label, input, version) => { + const { service } = setup([newKey()]); + expectAppError(() => service.decrypt(input), "Unsupported ciphertext version", { version }); + expectAppError(() => service.keyIdOf(input), "Unsupported ciphertext version", { version }); + }); + }); + + describe("unconfigured service", () => { + it("refuses to encrypt, decrypt, or evaluate rotation", () => { + const { service: configured } = setup([newKey()]); + const ciphertext = configured.encrypt("x"); + const { service } = setup([]); + expectAppError(() => service.encrypt("x"), "ENCRYPTION_KEY is not configured"); + expectAppError(() => service.decrypt(ciphertext), "ENCRYPTION_KEY is not configured"); + expectAppError(() => service.needsReencryption(ciphertext), "ENCRYPTION_KEY is not configured"); + }); + + it("still parses a key id out of a well-formed ciphertext", () => { + const { service: configured } = setup([newKey()]); + const ciphertext = configured.encrypt("x"); + const { service } = setup([]); + expect(service.keyIdOf(ciphertext)).toBe(configured.currentKeyId()); + }); + }); +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index fcfdf533d..31328cd18 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { MonitorService } from "../../../src/domain/monitors/monitor.service.ts"; import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; import type { IGeoChecksRepository } from "../../../src/domain/geo-checks/geo-check.repository.interface.ts"; +import type { IDockerLogsRepository } from "../../../src/domain/docker/docker-log.repository.interface.ts"; import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts"; import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; +import type { IEncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; const createMonitorsRepositoryMock = () => @@ -29,6 +32,7 @@ const createMonitorsRepositoryMock = () => const createChecksRepositoryMock = () => ({ findByDateRangeAndMonitorId: jest.fn(), + findDockerContainerChecks: jest.fn(), deleteByMonitorId: jest.fn(), }) as unknown as IChecksRepository; @@ -49,6 +53,12 @@ const createGeoChecksRepositoryMock = () => deleteByMonitorId: jest.fn(), }) as unknown as IGeoChecksRepository; +const createDockerLogsRepositoryMock = () => + ({ + findByContainerName: jest.fn(), + deleteByMonitorId: jest.fn().mockResolvedValue(0), + }) as unknown as IDockerLogsRepository; + const createIncidentsRepositoryMock = () => ({ deleteByMonitorId: jest.fn(), @@ -62,6 +72,17 @@ const createJobQueueMock = () => ({ deleteJob: jest.fn(), }); +const createEncryptionServiceMock = (overrides: Partial = {}) => + ({ + isConfigured: jest.fn(() => true), + currentKeyId: jest.fn(() => "key-1"), + keyIdOf: jest.fn(() => "key-1"), + needsReencryption: jest.fn(() => false), + encrypt: jest.fn((plaintext: string) => `enc:${plaintext}`), + decrypt: jest.fn((ciphertext: string) => ciphertext.replace(/^enc:/, "")), + ...overrides, + }) as unknown as IEncryptionService; + const TEAM_ID = "team-1"; const MONITOR_ID = "monitor-1"; const USER_ID = "user-1"; @@ -88,7 +109,9 @@ const createService = ( monitorStatsRepository?: IMonitorStatsRepository; statusPagesRepository?: IStatusPagesRepository; geoChecksRepository?: IGeoChecksRepository; + dockerLogsRepository?: IDockerLogsRepository; incidentsRepository?: IIncidentsRepository; + encryptionService?: IEncryptionService; jobQueue?: ReturnType; logger?: ReturnType; games?: Record; @@ -103,25 +126,28 @@ const createService = ( monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(), checksRepository: overrides.checksRepository ?? createChecksRepositoryMock(), geoChecksRepository: overrides.geoChecksRepository ?? createGeoChecksRepositoryMock(), + dockerLogsRepository: overrides.dockerLogsRepository ?? createDockerLogsRepositoryMock(), monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(), statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(), incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(), + encryptionService: overrides.encryptionService ?? createEncryptionServiceMock(), }); return { service, jobQueue, logger }; }; describe("MonitorService", () => { describe("createMonitor", () => { - it("creates a monitor and adds a job", async () => { + it("creates a monitor, adds a job and returns the created document", async () => { const monitorsRepository = createMonitorsRepositoryMock(); const monitor = makeMonitor(); (monitorsRepository.create as jest.Mock).mockResolvedValue(monitor); const { service, jobQueue } = createService({ monitorsRepository }); - await service.createMonitor(TEAM_ID, USER_ID, monitor as any); + const result = await service.createMonitor(TEAM_ID, USER_ID, monitor as any); expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID); expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor); + expect(result).toBe(monitor); }); it("strips a stray proxyId when proxyMode is not custom", async () => { @@ -237,8 +263,8 @@ describe("MonitorService", () => { expect(result.monitorData.monitor).toMatchObject({ id: MONITOR_ID }); }); - it("supports all uptime monitor types: ping, docker, port, game, grpc, websocket", async () => { - for (const monitorType of ["ping", "docker", "port", "game", "grpc", "websocket"] as const) { + it("supports all uptime monitor types: ping, port, game, grpc, websocket, dns", async () => { + for (const monitorType of ["ping", "port", "game", "grpc", "websocket", "dns"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); const checksRepository = createChecksRepositoryMock(); const monitorStatsRepository = createMonitorStatsRepositoryMock(); @@ -314,6 +340,27 @@ describe("MonitorService", () => { ); }); + it("throws 400 for unsupported monitor type (docker)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 0 }, + upChecks: { totalChecks: 0 }, + aggregate: [], + latest: null, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for uptime details" + ); + }); + it("uses different date ranges correctly", async () => { for (const dateRange of ["recent", "day", "week", "month", "all"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); @@ -398,6 +445,257 @@ describe("MonitorService", () => { }); }); + describe("getDockerDetailsById", () => { + it("returns docker details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 10 }, + upChecks: { totalChecks: 9 }, + aggregate: [{ _id: "2024-01-01T00:00:00Z", avgResponseTime: 12, upCount: 9, totalCount: 10, avgRunning: 3, avgTotal: 4, avgUnhealthy: 0 }], + latest: { + containers: [], + summary: { total: 4, running: 3, stopped: 1, unhealthy: 0 }, + checkedAt: "2024-01-01T00:00:00Z", + }, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats).toHaveProperty("aggregateData"); + expect(result.stats).toHaveProperty("upChecks"); + expect(result.stats).toHaveProperty("aggregate"); + expect(result.stats).toHaveProperty("latest"); + expect(result.stats.latest?.summary).toMatchObject({ total: 4, running: 3, stopped: 1, unhealthy: 0 }); + expect(result.monitorStats).toMatchObject({ monitorId: MONITOR_ID }); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for docker details" + ); + }); + + it("throws 500 when checksData monitorType is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http" }); + const { service } = createService({ monitorsRepository, checksRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "Unable to load docker stats for this monitor" + ); + }); + }); + + describe("getDockerContainerByName", () => { + const makeBucket = (overrides: Partial> = {}) => ({ + _id: "2024-01-01T00:00:00Z", + avgCpuPct: 12.5, + avgMemoryUsedBytes: 1024, + avgMemoryPct: 50, + minRestartCount: 0, + maxRestartCount: 0, + ...overrides, + }); + + it("returns container details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + const container = { id: "abc", name: "checkmate-mongo", image: "mongo:8.0", state: "running", status: "Up 3 hours", health: "healthy" }; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [makeBucket()], + latest: { container, checkedAt: "2024-01-01T00:00:00Z" }, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "recent", + }); + + expect(checksRepository.findDockerContainerChecks).toHaveBeenCalledWith(MONITOR_ID, "checkmate-mongo", "recent"); + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats.latest?.container).toMatchObject({ name: "checkmate-mongo" }); + expect(result.stats.restartsInRange).toBe(0); + }); + + it("sums restarts within and between buckets", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 0, maxRestartCount: 2 }), + makeBucket({ _id: "02", minRestartCount: 3, maxRestartCount: 3 }), + makeBucket({ _id: "03", minRestartCount: null, maxRestartCount: null }), + makeBucket({ _id: "04", minRestartCount: 3, maxRestartCount: 4 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // 2 within bucket 01, +1 between 01 and 02, +1 within 04 (null bucket skipped) + expect(result.stats.restartsInRange).toBe(4); + }); + + it("counts a restart-count drop as a recreate", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 5, maxRestartCount: 5 }), + makeBucket({ _id: "02", minRestartCount: 1, maxRestartCount: 1 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // counter dropped 5 -> 1: the recreate itself plus the 1 restart the new counter shows + expect(result.stats.restartsInRange).toBe(2); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect( + service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: "missing", containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("Monitor with ID missing not found."); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect( + service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("monitors are not supported for docker container details"); + }); + }); + + describe("getDockerContainerLogs", () => { + it("throws 404 when the monitor is not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: "missing", containerName: "web", limit: 20 })).rejects.toThrow( + "Monitor with ID missing not found" + ); + }); + + it("throws 400 for a non-docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 20 })).rejects.toThrow( + "monitors are not supported for docker container logs" + ); + }); + + it("passes pagination through and returns the last checkedAt as a full-page cursor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + const before = new Date("2026-01-02T00:00:00.000Z"); + const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }]; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "web", + before, + limit: 2, + }); + + expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", before, limit: 2 }); + expect(result.nextCursor).toBe("2026-01-01T01:00:00.000Z"); + }); + + it("returns a null cursor for a short page", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue([{ checkedAt: "2026-01-01T01:00:00.000Z" }]); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 2 }); + + expect(result.nextCursor).toBeNull(); + }); + + it("passes after through and returns a null cursor even for a full page", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + const after = new Date("2026-01-01T00:00:00.000Z"); + const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }]; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "web", + after, + limit: 2, + }); + + expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", after, limit: 2 }); + expect(result.logs).toBe(logs); + expect(result.nextCursor).toBeNull(); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); @@ -1606,9 +1904,11 @@ describe("MonitorService", () => { monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository: createDockerLogsRepositoryMock(), monitorStatsRepository, statusPagesRepository, incidentsRepository, + encryptionService: createEncryptionServiceMock(), }); const result = await service.deleteAllMonitors({ teamId: TEAM_ID }); @@ -1697,3 +1997,167 @@ describe("MonitorService", () => { }); }); }); + +describe("MonitorService — Docker TLS credentials", () => { + const KEY_PEM = "-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----"; + const tlsBody = { type: "docker" as const, url: "tcp://host:2376", dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: KEY_PEM }; + + const expectAppError = async (fn: () => Promise, status: number, message: string | RegExp) => { + let caught: unknown; + try { + await fn(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AppError); + expect((caught as AppError).status).toBe(status); + expect((caught as AppError).message).toMatch(message); + }; + + describe("createMonitor", () => { + it("encrypts the key and sets dockerTlsKeySet for a TLS url", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any); + + expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM); + expect(monitorsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem", dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }), + TEAM_ID, + USER_ID + ); + }); + + it("does not encrypt anything for a socket url and leaves dockerTlsKeySet false", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ type: "docker", url: "unix:///var/run/docker.sock" }) as any); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + const [created] = (monitorsRepository.create as jest.Mock).mock.calls[0] as [Record]; + expect(created.dockerTlsKeySet).toBe(false); + expect(created.dockerTlsKey).toBeUndefined(); + }); + + it("ignores TLS fields on non-docker monitors", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(makeMonitor()); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.createMonitor(TEAM_ID, USER_ID, makeMonitor({ dockerTlsKey: KEY_PEM }) as any); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + expect(monitorsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ dockerTlsKey: KEY_PEM }), TEAM_ID, USER_ID); + }); + + it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any }); + const { service } = createService({ monitorsRepository, encryptionService }); + + await expectAppError(() => service.createMonitor(TEAM_ID, USER_ID, makeMonitor(tlsBody) as any), 422, /ENCRYPTION_KEY/); + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + expect(monitorsRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe("editMonitor", () => { + it("keeps the stored key when the key field is blank", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: true })); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } }); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record]; + expect(patch).not.toHaveProperty("dockerTlsKey"); + expect(patch).not.toHaveProperty("dockerTlsKeySet"); + expect(patch).toMatchObject({ dockerTlsCa: "ca-pem", dockerTlsCert: "cert-pem" }); + }); + + it("rejects a blank key with a 422 when no key is stored", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker", dockerTlsKeySet: false })); + const { service } = createService({ monitorsRepository }); + + await expectAppError( + () => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { ...tlsBody, dockerTlsKey: "" } }), + 422, + /required for a TLS Docker host/ + ); + expect(monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("encrypts a replacement key without reading the stored monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody }); + + expect(monitorsRepository.findById).not.toHaveBeenCalled(); + expect(monitorsRepository.updateById).toHaveBeenCalledWith( + MONITOR_ID, + TEAM_ID, + expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }), + { unsetProxyId: false } + ); + }); + + it("encrypts a replacement key when the body omits the url", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { type: "docker", dockerTlsKey: KEY_PEM } }); + + expect(encryptionService.encrypt).toHaveBeenCalledWith(KEY_PEM); + expect(monitorsRepository.updateById).toHaveBeenCalledWith( + MONITOR_ID, + TEAM_ID, + expect.objectContaining({ dockerTlsKey: `enc:${KEY_PEM}`, dockerTlsKeySet: true }), + { unsetProxyId: false } + ); + }); + + it("clears dockerTlsKeySet when the url changes back to a socket", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + const encryptionService = createEncryptionServiceMock(); + const { service } = createService({ monitorsRepository, encryptionService }); + + await service.editMonitor({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + body: { type: "docker", url: "unix:///var/run/docker.sock", dockerTlsKey: KEY_PEM }, + }); + + expect(encryptionService.encrypt).not.toHaveBeenCalled(); + expect(monitorsRepository.findById).not.toHaveBeenCalled(); + const [, , patch] = (monitorsRepository.updateById as jest.Mock).mock.calls[0] as [string, string, Record]; + expect(patch.dockerTlsKeySet).toBe(false); + expect(patch.dockerTlsKey).toBeUndefined(); + }); + + it("rejects with a 422 naming ENCRYPTION_KEY when encryption is not configured", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const encryptionService = createEncryptionServiceMock({ isConfigured: jest.fn(() => false) as any }); + const { service } = createService({ monitorsRepository, encryptionService }); + + await expectAppError(() => service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: tlsBody }), 422, /ENCRYPTION_KEY/); + expect(monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/services/secretsRotationService.test.ts b/server/test/unit/services/secretsRotationService.test.ts new file mode 100644 index 000000000..c0caf9c34 --- /dev/null +++ b/server/test/unit/services/secretsRotationService.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import * as nodeCrypto from "node:crypto"; +import { EncryptionService } from "../../../src/service/encryption/encryptionService.ts"; +import { SecretsRotationService } from "../../../src/service/encryption/secretsRotationService.ts"; +import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +type Row = { id: string; dockerTlsKey: string }; + +const PLAINTEXT = "-----BEGIN PRIVATE KEY-----\nstub\n-----END PRIVATE KEY-----"; + +const newKey = () => nodeCrypto.randomBytes(32); + +const encryptionServiceFor = (keys: Buffer[]) => new EncryptionService(keys, createMockLogger(), nodeCrypto); + +const tamperData = (ciphertext: string) => { + const parts = ciphertext.split("."); + const data = parts[4] ?? ""; + parts[4] = (data[0] === "A" ? "B" : "A") + data.slice(1); + return parts.join("."); +}; + +const setup = (keys: Buffer[], rows: Row[]) => { + const encryptionService = encryptionServiceFor(keys); + const repository = { + findAllDockerTlsKeys: jest.fn(async () => rows), + updateDockerTlsKey: jest.fn(async () => undefined), + }; + const logger = createMockLogger(); + const service = new SecretsRotationService(repository as unknown as IMonitorsRepository, encryptionService, logger); + return { service, encryptionService, repository, logger }; +}; + +const updatedKeyFor = (repository: { updateDockerTlsKey: jest.Mock }, id: string): string => { + const call = repository.updateDockerTlsKey.mock.calls.find(([calledId]) => calledId === id); + expect(call).toBeDefined(); + return (call as [string, string])[1]; +}; + +describe("SecretsRotationService", () => { + it("re-encrypts stale rows under the current key and leaves current rows untouched", async () => { + const current = newKey(); + const previous = newKey(); + const stale = encryptionServiceFor([previous]); + const rows = [ + { id: "m-current", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + { id: "m-stale-1", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + { id: "m-stale-2", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + ]; + const { service, encryptionService, repository, logger } = setup([current, previous], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 3, reencrypted: 2, skipped: 0 }); + expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(2); + for (const id of ["m-stale-1", "m-stale-2"]) { + const rotated = updatedKeyFor(repository, id); + expect(encryptionService.keyIdOf(rotated)).toBe(encryptionService.currentKeyId()); + expect(encryptionService.decrypt(rotated)).toBe(PLAINTEXT); + } + expect(repository.updateDockerTlsKey).not.toHaveBeenCalledWith("m-current", expect.anything()); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ message: "Docker TLS key rotation: 2 re-encrypted, 0 skipped, 3 scanned", service: "SecretsRotationService" }) + ); + }); + + it("returns zero counts when every row is already under the current key", async () => { + const current = newKey(); + const rows = [ + { id: "m-1", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + { id: "m-2", dockerTlsKey: encryptionServiceFor([current]).encrypt(PLAINTEXT) }, + ]; + const { service, repository, logger } = setup([current], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 0 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 0 re-encrypted, 0 skipped, 2 scanned" })); + }); + + it("skips a row encrypted under an unknown key and logs its key id", async () => { + const unknown = encryptionServiceFor([newKey()]); + const rows = [{ id: "m-unknown", dockerTlsKey: unknown.encrypt(PLAINTEXT) }]; + const { service, repository, logger } = setup([newKey()], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 1, reencrypted: 0, skipped: 1 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-unknown"), + service: "SecretsRotationService", + details: { monitorId: "m-unknown", keyId: unknown.currentKeyId() }, + }) + ); + }); + + it("continues past a row that fails to decrypt", async () => { + const current = newKey(); + const previous = newKey(); + const stale = encryptionServiceFor([previous]); + const rows = [ + { id: "m-broken", dockerTlsKey: tamperData(stale.encrypt(PLAINTEXT)) }, + { id: "m-stale", dockerTlsKey: stale.encrypt(PLAINTEXT) }, + ]; + const { service, encryptionService, repository, logger } = setup([current, previous], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 1, skipped: 1 }); + expect(repository.updateDockerTlsKey).toHaveBeenCalledTimes(1); + expect(encryptionService.decrypt(updatedKeyFor(repository, "m-stale"))).toBe(PLAINTEXT); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Could not reencrypt Docker TLS key for monitor m-broken: Ciphertext failed authentication"), + details: expect.objectContaining({ monitorId: "m-broken" }), + }) + ); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Docker TLS key rotation: 1 re-encrypted, 1 skipped, 2 scanned" })); + }); + + it("warns and skips every row when ENCRYPTION_KEY is not configured", async () => { + const rows = [ + { id: "m-1", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) }, + { id: "m-2", dockerTlsKey: encryptionServiceFor([newKey()]).encrypt(PLAINTEXT) }, + ]; + const { service, repository, logger } = setup([], rows); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 2, reencrypted: 0, skipped: 2 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "2 monitor(s) have a stored Docker TLS key but ENCRYPTION_KEY is not set; their checks will fail until it is", + service: "SecretsRotationService", + }) + ); + expect(logger.info).not.toHaveBeenCalled(); + }); + + it("logs nothing when ENCRYPTION_KEY is not configured and there are no rows", async () => { + const { service, repository, logger } = setup([], []); + + const result = await service.run(); + + expect(result).toEqual({ scanned: 0, reencrypted: 0, skipped: 0 }); + expect(repository.updateDockerTlsKey).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); +}); diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts index 063a64f6d..92a31a89b 100644 --- a/server/test/unit/services/settingsService.test.ts +++ b/server/test/unit/services/settingsService.test.ts @@ -16,6 +16,7 @@ const makeEnv = (overrides?: Partial): ValidatedEnv => DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db", DB_TYPE: "mongodb", STATUS_PAGE_THEMES_ENABLED: false, + ENCRYPTION_KEY: [], ...overrides, }) as ValidatedEnv; diff --git a/server/test/unit/services/workerHelper.test.ts b/server/test/unit/services/workerHelper.test.ts index fe7dca42d..749804768 100644 --- a/server/test/unit/services/workerHelper.test.ts +++ b/server/test/unit/services/workerHelper.test.ts @@ -32,6 +32,9 @@ const createHelper = (overrides?: Record) => { const geoChecksRepository = { deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), }; + const dockerLogsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; const settingsService = { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), }; @@ -50,6 +53,7 @@ const createHelper = (overrides?: Record) => { checksRepository, incidentsRepository, geoChecksRepository, + dockerLogsRepository, ...overrides, }; @@ -63,7 +67,8 @@ const createHelper = (overrides?: Record) => { defaults.monitorStatsRepository as any, defaults.checksRepository as any, defaults.incidentsRepository as any, - defaults.geoChecksRepository as any + defaults.geoChecksRepository as any, + defaults.dockerLogsRepository as any ); return { helper, defaults }; }; @@ -81,6 +86,7 @@ describe("WorkerHelper", () => { (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + (defaults.dockerLogsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(7); (defaults.jobsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(6); const job = helper.getCleanupOrphanedJob(); @@ -93,12 +99,14 @@ describe("WorkerHelper", () => { expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.jobsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("7 orphaned docker logs") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("6 orphaned jobs") })); }); diff --git a/server/test/unit/utils/dockerHost.test.ts b/server/test/unit/utils/dockerHost.test.ts new file mode 100644 index 000000000..aa21c61c0 --- /dev/null +++ b/server/test/unit/utils/dockerHost.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@jest/globals"; +import { DOCKER_TLS_URL, isDockerSocketUrl, isDockerTlsUrl } from "../../../src/utils/dockerHost.ts"; + +describe("isDockerTlsUrl", () => { + it.each(["tcp://host", "tcp://host:2376", "https://host", "https://host:2377", "tcp://host/", "tcp://10.0.0.1:2376", " tcp://host "])( + "accepts %s", + (url) => { + expect(isDockerTlsUrl(url)).toBe(true); + } + ); + + it.each([ + "http://host", + "host:2376", + "tcp://host/x", + "tcp://host:abc", + "tcp://a:b:c", + "tcp://[::1]:2376", + "tcp://ho st", + "ssh://deploy@host", + "unix:///var/run/docker.sock", + "", + undefined, + ])("rejects %s", (url) => { + expect(isDockerTlsUrl(url)).toBe(false); + }); + + it("captures scheme, host and port for the provider", () => { + expect(DOCKER_TLS_URL.exec("https://host:2377")?.slice(1)).toEqual(["https", "host", "2377"]); + expect(DOCKER_TLS_URL.exec("tcp://host")?.slice(1)).toEqual(["tcp", "host", undefined]); + }); +}); + +describe("isDockerSocketUrl", () => { + it.each(["unix:///var/run/docker.sock", "/var/run/docker.sock", " /run/docker.sock "])("accepts %s", (url) => { + expect(isDockerSocketUrl(url)).toBe(true); + }); + + it.each(["unix://", "unix://relative/path", "tcp://host", "my-container", "", undefined])("rejects %s", (url) => { + expect(isDockerSocketUrl(url)).toBe(false); + }); +}); diff --git a/server/test/unit/utils/pem.test.ts b/server/test/unit/utils/pem.test.ts new file mode 100644 index 000000000..31d9b6d49 --- /dev/null +++ b/server/test/unit/utils/pem.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@jest/globals"; +import { readFileSync } from "node:fs"; +import { X509Certificate } from "node:crypto"; +import { keyMatchesCertificate, parseCertificates, parsePrivateKey, splitCertificateBundle } from "../../../src/utils/pem.ts"; + +const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8"); + +const CA = fixture("ca.pem"); +const CLIENT_CERT = fixture("client-cert.pem"); +const CLIENT_KEY = fixture("client-key.pem"); +const OTHER_KEY = fixture("other-key.pem"); +const ENCRYPTED_KEY = fixture("encrypted-key.pem"); + +describe("splitCertificateBundle", () => { + it("returns one block for a single certificate", () => { + expect(splitCertificateBundle(CA)).toHaveLength(1); + }); + + it("returns every block of a bundle in order", () => { + const blocks = splitCertificateBundle(`${CA}\n${CLIENT_CERT}`); + expect(blocks).toHaveLength(2); + expect(blocks[0]).toBe(CA.trim()); + expect(blocks[1]).toBe(CLIENT_CERT.trim()); + }); + + it("ignores text outside the blocks", () => { + expect(splitCertificateBundle(`subject=CN=x\n${CA}\ntrailing`)).toHaveLength(1); + }); + + it("returns an empty array when there is no certificate block", () => { + expect(splitCertificateBundle("")).toEqual([]); + expect(splitCertificateBundle(CLIENT_KEY)).toEqual([]); + expect(splitCertificateBundle("-----BEGIN CERTIFICATE-----\nunterminated")).toEqual([]); + }); +}); + +describe("parseCertificates", () => { + it("parses a single certificate", () => { + const [cert] = parseCertificates(CA); + expect(cert).toBeInstanceOf(X509Certificate); + expect(cert?.subject).toContain("CN=Checkmate Test CA"); + }); + + it("parses every certificate in a bundle", () => { + const certs = parseCertificates(`${CA}\n${CLIENT_CERT}`); + expect(certs.map((c) => c.subject)).toEqual([expect.stringContaining("Checkmate Test CA"), expect.stringContaining("checkmate-test-client")]); + }); + + it("rejects input with no certificate block", () => { + expect(() => parseCertificates("")).toThrow("No certificate found"); + expect(() => parseCertificates(CLIENT_KEY)).toThrow("No certificate found"); + }); + + it("rejects a block whose body is not a certificate", () => { + expect(() => parseCertificates("-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----")).toThrow( + "Certificate is not valid PEM" + ); + }); +}); + +describe("parsePrivateKey", () => { + it("parses an unencrypted PKCS#8 key", () => { + expect(parsePrivateKey(CLIENT_KEY).type).toBe("private"); + }); + + it("rejects an encrypted PKCS#8 key with a clear message", () => { + expect(() => parsePrivateKey(ENCRYPTED_KEY)).toThrow("Encrypted private keys are not supported"); + }); + + it("rejects a legacy encrypted key by its Proc-Type header", () => { + const legacy = "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nAAAA\n-----END RSA PRIVATE KEY-----"; + expect(() => parsePrivateKey(legacy)).toThrow("Encrypted private keys are not supported"); + }); + + it("rejects input that is not a key", () => { + expect(() => parsePrivateKey("")).toThrow("Private key is not valid PEM"); + expect(() => parsePrivateKey(CA)).toThrow("Private key is not valid PEM"); + }); +}); + +describe("keyMatchesCertificate", () => { + it("is true for the key the certificate was issued to", () => { + const [cert] = parseCertificates(CLIENT_CERT); + expect(keyMatchesCertificate(parsePrivateKey(CLIENT_KEY), cert!)).toBe(true); + }); + + it("is false for an unrelated key", () => { + const [cert] = parseCertificates(CLIENT_CERT); + expect(keyMatchesCertificate(parsePrivateKey(OTHER_KEY), cert!)).toBe(false); + }); +}); diff --git a/server/test/unit/validation/authValidation.test.ts b/server/test/unit/validation/authValidation.test.ts new file mode 100644 index 000000000..89cdf60ac --- /dev/null +++ b/server/test/unit/validation/authValidation.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "@jest/globals"; +import { registerInviteTokenValidation } from "../../../src/api/validation/authValidation.ts"; + +describe("authValidation", () => { + describe("registerInviteTokenValidation", () => { + it("accepts a valid token string", () => { + expect(registerInviteTokenValidation.parse("a".repeat(64))).toBe("a".repeat(64)); + }); + + it("defaults an absent token to an empty string", () => { + expect(registerInviteTokenValidation.parse(undefined)).toBe(""); + }); + + it("rejects a NoSQL operator object in place of a token", () => { + expect(() => registerInviteTokenValidation.parse({ $ne: null })).toThrow(); + }); + + it("rejects an array token", () => { + expect(() => registerInviteTokenValidation.parse(["a", "b"])).toThrow(); + }); + }); +}); diff --git a/server/test/unit/validation/envValidation.test.ts b/server/test/unit/validation/envValidation.test.ts new file mode 100644 index 000000000..e34610400 --- /dev/null +++ b/server/test/unit/validation/envValidation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@jest/globals"; +import { randomBytes } from "node:crypto"; +import { encryptionKeyList } from "../../../src/config/envValidation.ts"; + +const key = () => randomBytes(32).toString("base64"); +const decoded = (k: string) => Buffer.from(k, "base64"); + +const issueMessages = (input: string) => { + const result = encryptionKeyList.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues.map((issue) => issue.message); +}; + +describe("encryptionKeyList", () => { + it("is an empty list when unset or blank", () => { + expect(encryptionKeyList.parse(undefined)).toEqual([]); + expect(encryptionKeyList.parse("")).toEqual([]); + expect(encryptionKeyList.parse(" , ")).toEqual([]); + }); + + it("accepts one key, trimming whitespace and a trailing comma", () => { + const k = key(); + expect(encryptionKeyList.parse(k)).toEqual([decoded(k)]); + expect(encryptionKeyList.parse(` ${k} ,`)).toEqual([decoded(k)]); + }); + + it("accepts several keys in the given order", () => { + const a = key(); + const b = key(); + expect(encryptionKeyList.parse(`${a},${b}`)).toEqual([decoded(a), decoded(b)]); + }); + + it.each([ + ["43 characters", key().slice(0, 43)], + ["no trailing =", key().slice(0, 43) + "A"], + ["a space inside", key().replace(/^(.{10})/, "$1 ")], + ["base64url alphabet", key().replace(/[+/]/g, "-").replace(/=$/, "")], + ["31 bytes", randomBytes(31).toString("base64")], + ["33 bytes", randomBytes(33).toString("base64")], + ])("rejects an entry that is not 32 standard-base64 bytes (%s)", (_label, bad) => { + expect(issueMessages(`${key()},${bad}`)).toContain("ENCRYPTION_KEY entry 2 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"); + }); + + it("reports every bad entry, not just the first", () => { + const messages = issueMessages(`${key().slice(0, 43)},${key()},${randomBytes(31).toString("base64")}`); + expect(messages).toContain("ENCRYPTION_KEY entry 1 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"); + expect(messages).toContain("ENCRYPTION_KEY entry 3 must be 32 bytes as padded standard base64 (openssl rand -base64 32)"); + expect(messages).toHaveLength(2); + }); + + it("numbers entries by comma position, counting blank slots", () => { + const bad = randomBytes(31).toString("base64"); + expect(issueMessages(`${key()},,${bad}`)).toContain( + "ENCRYPTION_KEY entry 3 must be 32 bytes as padded standard base64 (openssl rand -base64 32)" + ); + }); + + it("rejects the same key listed twice", () => { + const k = key(); + expect(issueMessages(`${k},${k}`)).toContain("ENCRYPTION_KEY entry 2 duplicates an earlier entry"); + }); + + it("never echoes the entry in an issue message", () => { + const bad = randomBytes(31).toString("base64"); + for (const message of issueMessages(bad)) { + expect(message).not.toContain(bad); + } + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index ea2257072..5b626d329 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -1,10 +1,34 @@ import { describe, expect, it } from "@jest/globals"; +import { readFileSync } from "node:fs"; import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation, + getDockerContainerLogsQueryValidation, } from "../../../src/api/validation/monitorValidation.ts"; +describe("getDockerContainerLogsQueryValidation", () => { + it("defaults limit to 20", () => { + expect(getDockerContainerLogsQueryValidation.parse({})).toEqual({ limit: 20 }); + }); + + it("rejects a limit above 50", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ limit: 51 })).toThrow(); + }); + + it("rejects a non-ISO before cursor", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ before: "yesterday" })).toThrow(); + }); + + it("rejects a non-ISO after cursor", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ after: "yesterday" })).toThrow(); + }); + + it("rejects before and after together", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ before: "2026-01-02T00:00:00.000Z", after: "2026-01-01T00:00:00.000Z" })).toThrow(); + }); +}); + const baseDnsBody = { name: "DNS check", type: "dns" as const, @@ -210,6 +234,19 @@ describe("monitorValidation — strategy gating", () => { expect(parsed.monitors[0].strategy).toBeUndefined(); }); + it("defaults dockerLogsEnabled to false on imported docker monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported Docker", + type: "docker", + url: "unix:///var/run/docker.sock", + }, + ], + }); + expect(parsed.monitors[0].dockerLogsEnabled).toBe(false); + }); + it("rejects strategy on imported non-pagespeed monitors", () => { expect(() => importMonitorsBodyValidation.parse({ @@ -565,3 +602,200 @@ describe("monitorValidation — proxy fields", () => { }); }); }); + +describe("monitorValidation — Docker host url", () => { + const baseDockerBody = { + name: "Docker host check", + type: "docker" as const, + url: "unix:///var/run/docker.sock", + }; + + describe("createMonitorBodyValidation", () => { + it("accepts the default unix socket url", () => { + const parsed = createMonitorBodyValidation.parse(baseDockerBody); + expect(parsed.url).toBe("unix:///var/run/docker.sock"); + }); + + it("accepts a bare absolute socket path", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url: "/run/docker.sock" }); + expect(parsed.url).toBe("/run/docker.sock"); + }); + + it("accepts tcp:// and https:// engine urls with an optional port and trailing slash", () => { + const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8"); + const tlsFields = { dockerTlsCa: fixture("ca.pem"), dockerTlsCert: fixture("client-cert.pem"), dockerTlsKey: fixture("client-key.pem") }; + for (const url of ["tcp://host", "tcp://host:2376", "https://host:2377", "tcp://host/"]) { + expect(createMonitorBodyValidation.parse({ ...baseDockerBody, ...tlsFields, url }).url).toBe(url); + } + }); + + it("rejects container names and unsupported engine urls", () => { + for (const badUrl of [ + "my-container", + "http://host", + "host:2376", + "tcp://host/x", + "tcp://host:abc", + "ssh://deploy@host", + "unix://relative/path", + "", + ]) { + expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); + } + }); + + it("does not apply docker url rules to other monitor types", () => { + const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", type: "http", url: "https://example.com" }); + expect(parsed.url).toBe("https://example.com"); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("allows a docker edit without a url (partial edit)", () => { + const parsed = editMonitorBodyValidation.parse({ type: "docker", name: "renamed" }); + expect(parsed.url).toBeUndefined(); + }); + + it("rejects a docker edit with an invalid url", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow(); + }); + + it("rejects a docker edit with an ssh url", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("accepts an imported docker monitor with a socket url", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseDockerBody] }); + expect(parsed.monitors[0].url).toBe("unix:///var/run/docker.sock"); + }); + + it("rejects an imported docker monitor with a container-name url", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseDockerBody, url: "my-container" }] })).toThrow(); + }); + }); +}); + +describe("monitorValidation — Docker TLS credentials", () => { + const fixture = (name: string) => readFileSync(new URL(`../../fixtures/docker-tls/${name}`, import.meta.url), "utf8"); + const CA = fixture("ca.pem"); + const CLIENT_CERT = fixture("client-cert.pem"); + const CLIENT_KEY = fixture("client-key.pem"); + const OTHER_KEY = fixture("other-key.pem"); + const ENCRYPTED_KEY = fixture("encrypted-key.pem"); + + const baseTlsBody = { + name: "Docker TLS check", + type: "docker" as const, + url: "tcp://host:2376", + }; + const fullTlsBody = { ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: CLIENT_KEY }; + + const issuesFor = (result: ReturnType) => + result.success ? [] : result.error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message })); + + describe("createMonitorBodyValidation", () => { + it("accepts a CA, client certificate, and matching key", () => { + const parsed = createMonitorBodyValidation.parse(fullTlsBody); + expect(parsed.dockerTlsCa).toBe(CA); + expect(parsed.dockerTlsCert).toBe(CLIENT_CERT); + expect(parsed.dockerTlsKey).toBe(CLIENT_KEY); + }); + + it("does not require TLS fields for a socket url", () => { + expect(createMonitorBodyValidation.safeParse({ ...baseTlsBody, url: "unix:///var/run/docker.sock" }).success).toBe(true); + }); + + it("requires the client certificate, key, and CA for a TLS url", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse(baseTlsBody)); + expect(issues).toEqual( + expect.arrayContaining([ + { path: "dockerTlsCert", message: "TLS certificate is required for a TLS Docker host" }, + { path: "dockerTlsKey", message: "TLS key is required for a TLS Docker host" }, + { path: "dockerTlsCa", message: "CA certificate is required unless TLS errors are ignored" }, + ]) + ); + }); + + it("makes the CA optional when TLS errors are ignored", () => { + const result = createMonitorBodyValidation.safeParse({ + ...baseTlsBody, + ignoreTlsErrors: true, + dockerTlsCert: CLIENT_CERT, + dockerTlsKey: CLIENT_KEY, + }); + expect(result.success).toBe(true); + }); + + it("still requires the client certificate and key when TLS errors are ignored", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...baseTlsBody, ignoreTlsErrors: true })); + expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsKey"])); + expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsCa"); + }); + + it("rejects a CA that is not a PEM certificate", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: "not a certificate" })); + expect(issues).toEqual([{ path: "dockerTlsCa", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]); + }); + + it("rejects a client certificate that is not a PEM certificate", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCert: "not a certificate" })); + expect(issues).toEqual([{ path: "dockerTlsCert", message: "No certificate found; expected one or more PEM CERTIFICATE blocks" }]); + }); + + it("rejects a key that is not a PEM private key", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: "not a key" })); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Private key is not valid PEM" }]); + }); + + it("rejects an encrypted private key", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: ENCRYPTED_KEY })); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Encrypted private keys are not supported; remove the passphrase first" }]); + }); + + it("rejects a key that does not match the client certificate", () => { + const issues = issuesFor(createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsKey: OTHER_KEY })); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]); + }); + + it("accepts a CA bundle with more than one certificate", () => { + const result = createMonitorBodyValidation.safeParse({ ...fullTlsBody, dockerTlsCa: `${CA}\n${CLIENT_CERT}` }); + expect(result.success).toBe(true); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("accepts a TLS url without a key so a stored key is kept", () => { + const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT }); + expect(result.success).toBe(true); + }); + + it("accepts a blank key so a stored key is kept", () => { + const result = editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: "" }); + expect(result.success).toBe(true); + }); + + it("still requires the client certificate and CA", () => { + const issues = issuesFor(editMonitorBodyValidation.safeParse(baseTlsBody)); + expect(issues.map((issue) => issue.path)).toEqual(expect.arrayContaining(["dockerTlsCert", "dockerTlsCa"])); + expect(issues.map((issue) => issue.path)).not.toContain("dockerTlsKey"); + }); + + it("validates a replacement key against the client certificate", () => { + const issues = issuesFor( + editMonitorBodyValidation.safeParse({ ...baseTlsBody, dockerTlsCa: CA, dockerTlsCert: CLIENT_CERT, dockerTlsKey: OTHER_KEY }) + ); + expect(issues).toEqual([{ path: "dockerTlsKey", message: "Key does not match certificate" }]); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("strips TLS credentials from imported monitors instead of validating them", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [fullTlsBody] }); + expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCa"); + expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsCert"); + expect(parsed.monitors[0]).not.toHaveProperty("dockerTlsKey"); + }); + }); +});