Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5766a55a9 | ||
|
|
c5902b397a | ||
|
|
e52ec2d6ee | ||
|
|
a2c978527f | ||
|
|
224dc60309 | ||
|
|
d8e4c775d5 | ||
|
|
a07d0059b7 | ||
|
|
1a48d303f7 | ||
|
|
5bb9ca970c | ||
|
|
2947b558a2 | ||
|
|
98a5fa9941 | ||
|
|
712350d669 | ||
|
|
1a287be323 | ||
|
|
014750f91e | ||
|
|
574078bdc8 | ||
|
|
7ab80ac3f4 | ||
|
|
a258910ccf | ||
|
|
e15a85bd61 | ||
|
|
ced90211a0 | ||
|
|
4ae8426766 | ||
|
|
32a5ca8799 | ||
|
|
5d77b8804f | ||
|
|
544502c6e5 | ||
|
|
1ef540679b | ||
|
|
18f38818e2 | ||
|
|
02e6333c3c | ||
|
|
cb47439e9f | ||
|
|
4bbfe2e889 | ||
|
|
187a0fc3d4 | ||
|
|
4e4d3c3971 | ||
|
|
6267e08a51 | ||
|
|
f2c23636fe | ||
|
|
11c89d150a | ||
|
|
6b75f9bf7c | ||
|
|
91de9e7fb7 | ||
|
|
21fb320f3a | ||
|
|
3586ac9437 | ||
|
|
b0ed75382e | ||
|
|
b1646cd83b | ||
|
|
7abc74ac98 | ||
|
|
a1b10c7469 | ||
|
|
0d75629dcb | ||
|
|
849ea26cf9 | ||
|
|
4a66cf6e11 | ||
|
|
41c7cfbdaa | ||
|
|
7fb57d8090 | ||
|
|
618258d282 | ||
|
|
e50568dba3 | ||
|
|
5fa894cb79 | ||
|
|
6d4e944a58 | ||
|
|
cfe7d2a359 | ||
|
|
5fb26367ed | ||
|
|
cd65488cbd | ||
|
|
4ea6539a7d | ||
|
|
a75bb5fae6 | ||
|
|
380968472f | ||
|
|
0cb48596a0 | ||
|
|
22d0123966 | ||
|
|
2a45b4d46f | ||
|
|
bdd4634a3b | ||
|
|
a5e66318b5 | ||
|
|
7ebbb05482 | ||
|
|
99c62961cf | ||
|
|
40a9f3f1e1 | ||
|
|
b37e4d9083 | ||
|
|
37dd9fad26 | ||
|
|
0f51d68ff0 | ||
|
|
dfc048522d | ||
|
|
1f9072a905 | ||
|
|
780ed8147b | ||
|
|
b2cfc60387 | ||
|
|
c8f84e9e20 | ||
|
|
9434cd8929 | ||
|
|
a277a89bef | ||
|
|
55184724d0 | ||
|
|
de4c528d55 | ||
|
|
d5e887903e | ||
|
|
6196b81fe4 | ||
|
|
c3598a67fa | ||
|
|
2c896a5506 | ||
|
|
97e23e4b3a | ||
|
|
be39ad0bfe | ||
|
|
7f0e577dd2 | ||
|
|
f308325bd6 | ||
|
|
c5303add56 | ||
|
|
fe6f755451 | ||
|
|
4a2429d316 | ||
|
|
d7f8bcc9e1 | ||
|
|
d51fdbd772 | ||
|
|
199d68e8a2 | ||
|
|
461a5e35dd | ||
|
|
8dd57d45e2 | ||
|
|
01e9655f42 | ||
|
|
d4d2735d23 | ||
|
|
253642dde9 | ||
|
|
9d0dc3f330 | ||
|
|
2f3de9797d | ||
|
|
1b3b8e5312 | ||
|
|
b55560e2f1 | ||
|
|
57ddbc474f | ||
|
|
862ff15eef | ||
|
|
da8b230cec | ||
|
|
13799af721 | ||
|
|
4fcb13694b | ||
|
|
3dc529733a | ||
|
|
d865103fb5 | ||
|
|
4335e6b7b6 | ||
|
|
4f5dba560d | ||
|
|
f52acb3f68 | ||
|
|
f4864b5455 | ||
|
|
f299098548 | ||
|
|
87eab6f996 | ||
|
|
31400a2502 | ||
|
|
fa0c0abcac | ||
|
|
9102335d3e | ||
|
|
011b93ecc1 | ||
|
|
20ce11497e | ||
|
|
50d0721822 | ||
|
|
c66872c7f4 | ||
|
|
b1be38368c | ||
|
|
278f652ea9 | ||
|
|
d3e12722b7 | ||
|
|
8c9ff41074 | ||
|
|
71a1194373 | ||
|
|
580a025353 | ||
|
|
33994cb3df | ||
|
|
44edf320a0 | ||
|
|
fea2ef4dbe | ||
|
|
360b4b8035 | ||
|
|
3857a71e62 | ||
|
|
b68367b04f | ||
|
|
08f104e283 | ||
|
|
46eb0b4063 | ||
|
|
2632f98ad0 | ||
|
|
84539f76cf | ||
|
|
308672d558 | ||
|
|
0f474d82d6 | ||
|
|
650cfe00ce | ||
|
|
69eb9957c6 | ||
|
|
ec01a5dfe4 | ||
|
|
b2ace1bec5 | ||
|
|
5da472da14 | ||
|
|
4bb366f5ab | ||
|
|
cc4b8b38f5 | ||
|
|
86b59da909 | ||
|
|
71dd998390 | ||
|
|
896d73f12e | ||
|
|
c9b99eabb4 |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
|
|||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||||
|
|
||||||
### Backend Layers
|
### Backend Layers
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
|||||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||||
|
|
||||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
||||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). 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.
|
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
|
||||||
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
@@ -134,14 +134,17 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
|
|||||||
|
|
||||||
### Docker monitors
|
### 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.
|
A Docker monitor reports on every container running on a host. It can connect to the Docker daemon directly or read container metrics from a Capture agent. Direct monitors use the daemon's ping response for availability and record container state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every direct check; logs are kept for 7 days.
|
||||||
|
|
||||||
The **Docker host** field accepts two forms:
|
The **Docker host** field accepts three forms:
|
||||||
|
|
||||||
| Host | Example | Notes |
|
| 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. |
|
| 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. |
|
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
|
||||||
|
| Capture agent | `https://capture.example.com/api/v1/metrics/docker` | Enter the full endpoint ending in `/metrics/docker` and its authorization secret. Checkmate requests `all=true` so stopped containers are included. |
|
||||||
|
|
||||||
|
Capture-backed monitors normalize Capture's container state, health, CPU, memory, start time and exposed ports into the same Docker pages. Capture does not currently provide restart counts, mounts, published host-port bindings or container logs, so those values are shown as unavailable. Capture also reports a container whose health check is still starting as healthy, so the `starting` health state only appears on direct monitors, and stopped containers carry no CPU or memory metrics.
|
||||||
|
|
||||||
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
|
**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:
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-22
@@ -93,7 +93,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -409,7 +408,6 @@
|
|||||||
"node_modules/@emotion/react": {
|
"node_modules/@emotion/react": {
|
||||||
"version": "11.14.0",
|
"version": "11.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -447,7 +445,6 @@
|
|||||||
"node_modules/@emotion/styled": {
|
"node_modules/@emotion/styled": {
|
||||||
"version": "11.14.1",
|
"version": "11.14.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -1236,7 +1233,6 @@
|
|||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "7.3.7",
|
"version": "7.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/core-downloads-tracker": "^7.3.7",
|
"@mui/core-downloads-tracker": "^7.3.7",
|
||||||
@@ -1341,7 +1337,6 @@
|
|||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "7.3.7",
|
"version": "7.3.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/private-theming": "^7.3.7",
|
"@mui/private-theming": "^7.3.7",
|
||||||
@@ -2386,7 +2381,6 @@
|
|||||||
"version": "24.5.2",
|
"version": "24.5.2",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.12.0"
|
"undici-types": "~7.12.0"
|
||||||
}
|
}
|
||||||
@@ -2402,7 +2396,6 @@
|
|||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2493,7 +2486,6 @@
|
|||||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "7.18.0",
|
"@typescript-eslint/scope-manager": "7.18.0",
|
||||||
"@typescript-eslint/types": "7.18.0",
|
"@typescript-eslint/types": "7.18.0",
|
||||||
@@ -2774,7 +2766,6 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3131,7 +3122,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.42",
|
"baseline-browser-mapping": "^2.10.42",
|
||||||
"caniuse-lite": "^1.0.30001800",
|
"caniuse-lite": "^1.0.30001800",
|
||||||
@@ -3575,8 +3565,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.13",
|
"version": "1.11.13",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
@@ -3975,7 +3964,6 @@
|
|||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -4788,7 +4776,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -5600,7 +5587,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
|
||||||
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/geojson-rewind": "^0.5.2",
|
"@mapbox/geojson-rewind": "^0.5.2",
|
||||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
@@ -6382,7 +6368,6 @@
|
|||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -6394,7 +6379,6 @@
|
|||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.71.1",
|
"version": "7.71.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -6598,8 +6582,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/redux-persist": {
|
"node_modules/redux-persist": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
@@ -6749,7 +6732,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.9"
|
"@types/estree": "1.0.9"
|
||||||
},
|
},
|
||||||
@@ -7591,7 +7573,6 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -7729,7 +7710,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.12.0",
|
"version": "3.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
if (!monitor) {
|
if (!monitor) {
|
||||||
@@ -52,7 +55,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
<Typography>
|
<Typography>
|
||||||
Checking every {formatDuration(monitor?.interval, true)}
|
{t("pages.common.monitors.statBoxes.checkingEvery", {
|
||||||
|
interval: formatDuration(monitor.interval, {
|
||||||
|
long: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
type FieldValues,
|
type FieldValues,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Autocomplete } from "@/Components/inputs";
|
import { Autocomplete } from "@/Components/inputs";
|
||||||
|
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
||||||
|
|
||||||
export interface AutocompleteOption {
|
export interface AutocompleteOption {
|
||||||
id: string;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
|
|||||||
options: O[];
|
options: O[];
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
|
||||||
|
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
|
||||||
|
disableClearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormAutocompleteField = <
|
export const FormAutocompleteField = <
|
||||||
@@ -26,6 +30,9 @@ export const FormAutocompleteField = <
|
|||||||
options,
|
options,
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
filterOptions,
|
||||||
|
getOptionDisabled,
|
||||||
|
disableClearable,
|
||||||
}: FormAutocompleteProps<T, O>) => {
|
}: FormAutocompleteProps<T, O>) => {
|
||||||
const { control } = useFormContext<T>();
|
const { control } = useFormContext<T>();
|
||||||
return (
|
return (
|
||||||
@@ -39,10 +46,14 @@ export const FormAutocompleteField = <
|
|||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
value={options.find((o) => o.id === field.value) ?? null}
|
value={options.find((o) => o.id === field.value) ?? null}
|
||||||
onChange={(_: unknown, newValue: O | null) => {
|
onChange={(_: unknown, newValue: O | null) => {
|
||||||
|
if (newValue === null && disableClearable) return;
|
||||||
field.onChange(newValue?.id ?? "");
|
field.onChange(newValue?.id ?? "");
|
||||||
}}
|
}}
|
||||||
fieldLabel={fieldLabel}
|
fieldLabel={fieldLabel}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
getOptionDisabled={getOptionDisabled}
|
||||||
|
disableClearable={disableClearable}
|
||||||
error={!!fieldState.error}
|
error={!!fieldState.error}
|
||||||
helperText={fieldState.error?.message ?? ""}
|
helperText={fieldState.error?.message ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
|
|||||||
domainExpiry,
|
domainExpiry,
|
||||||
}: MonitorStatBoxesProps) => {
|
}: MonitorStatBoxesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
if (!monitorStats || !monitor) {
|
if (!monitorStats || !monitor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -29,13 +29,16 @@ export const MonitorStatBoxes = ({
|
|||||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||||
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
||||||
|
|
||||||
// Determine time since last check
|
|
||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
|
||||||
|
|
||||||
const streakTime = formatDuration(timeSinceLastFailure);
|
const streakTime = formatDuration(timeSinceLastFailure);
|
||||||
|
const lastCheckTime = timeOfLastCheck
|
||||||
const lastCheckTime = formatDuration(timeSinceLastCheck);
|
? formatDuration(Date.now() - timeOfLastCheck, {
|
||||||
|
long: true,
|
||||||
|
relative: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
})
|
||||||
|
: t("common.labels.na");
|
||||||
const isActive =
|
const isActive =
|
||||||
monitor?.status === "up" ||
|
monitor?.status === "up" ||
|
||||||
monitor?.status === "paused" ||
|
monitor?.status === "paused" ||
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Container,
|
Container,
|
||||||
|
// LayoutDashboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
export const getMenu = (t: Function) => {
|
export const getMenu = (t: Function) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
group: t("components.sidebar.menu.groups.monitoring"),
|
group: t("components.sidebar.menu.groups.monitoring"),
|
||||||
items: [
|
items: [
|
||||||
|
// {
|
||||||
|
// name: t("components.sidebar.menu.dashboard"),
|
||||||
|
// path: "dashboard",
|
||||||
|
// icon: <Icon icon={LayoutDashboard} />,
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
name: t("components.sidebar.menu.uptime"),
|
name: t("components.sidebar.menu.uptime"),
|
||||||
path: "uptime",
|
path: "uptime",
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import {
|
||||||
|
dashboardCardKeys,
|
||||||
|
type DashboardCardKey,
|
||||||
|
type DashboardSortOrder,
|
||||||
|
} from "@/Types/Dashboard";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
type ThemeMode = "light" | "dark";
|
type ThemeMode = "light" | "dark";
|
||||||
type ChartType = "histogram" | "line";
|
type ChartType = "histogram" | "line";
|
||||||
|
|
||||||
type TableName =
|
type TableName =
|
||||||
|
| "dashboard"
|
||||||
| "monitors"
|
| "monitors"
|
||||||
| "team"
|
| "team"
|
||||||
| "maintenance"
|
| "maintenance"
|
||||||
@@ -13,6 +21,8 @@ type TableName =
|
|||||||
|
|
||||||
interface TableState {
|
interface TableState {
|
||||||
rowsPerPage: number;
|
rowsPerPage: number;
|
||||||
|
sortField?: string;
|
||||||
|
sortOrder?: SortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarState {
|
interface SidebarState {
|
||||||
@@ -20,6 +30,7 @@ interface SidebarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
|
dashboard: TableState;
|
||||||
monitors: TableState;
|
monitors: TableState;
|
||||||
pagespeed: TableState;
|
pagespeed: TableState;
|
||||||
team: TableState;
|
team: TableState;
|
||||||
@@ -35,6 +46,8 @@ interface UIState {
|
|||||||
language: string;
|
language: string;
|
||||||
starPromptOpen: boolean;
|
starPromptOpen: boolean;
|
||||||
chartType: ChartType;
|
chartType: ChartType;
|
||||||
|
dashboardVisibleCards: DashboardCardKey[];
|
||||||
|
dashboardUptimeSortOrder: DashboardSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||||
@@ -43,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
|
|||||||
: "light";
|
: "light";
|
||||||
|
|
||||||
const initialState: UIState = {
|
const initialState: UIState = {
|
||||||
|
dashboard: {
|
||||||
|
rowsPerPage: 100,
|
||||||
|
},
|
||||||
monitors: {
|
monitors: {
|
||||||
rowsPerPage: 10,
|
rowsPerPage: 10,
|
||||||
},
|
},
|
||||||
@@ -74,6 +90,8 @@ const initialState: UIState = {
|
|||||||
language: "en",
|
language: "en",
|
||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
chartType: "histogram",
|
||||||
|
dashboardVisibleCards: [...dashboardCardKeys],
|
||||||
|
dashboardUptimeSortOrder: "ascending",
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -90,6 +108,14 @@ const uiSlice = createSlice({
|
|||||||
const { table, value } = action.payload;
|
const { table, value } = action.payload;
|
||||||
state[table].rowsPerPage = value;
|
state[table].rowsPerPage = value;
|
||||||
},
|
},
|
||||||
|
setTableSort: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
|
||||||
|
) => {
|
||||||
|
const { table, field, order } = action.payload;
|
||||||
|
state[table].sortField = field;
|
||||||
|
state[table].sortOrder = order;
|
||||||
|
},
|
||||||
toggleSidebar: (state) => {
|
toggleSidebar: (state) => {
|
||||||
state.sidebar.collapsed = !state.sidebar.collapsed;
|
state.sidebar.collapsed = !state.sidebar.collapsed;
|
||||||
},
|
},
|
||||||
@@ -117,6 +143,12 @@ const uiSlice = createSlice({
|
|||||||
setChartType: (state, action: PayloadAction<ChartType>) => {
|
setChartType: (state, action: PayloadAction<ChartType>) => {
|
||||||
state.chartType = action.payload;
|
state.chartType = action.payload;
|
||||||
},
|
},
|
||||||
|
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
|
||||||
|
state.dashboardVisibleCards = action.payload;
|
||||||
|
},
|
||||||
|
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
|
||||||
|
state.dashboardUptimeSortOrder = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
|
|||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
export const {
|
export const {
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
|
setTableSort,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setCollapsed,
|
setCollapsed,
|
||||||
setMode,
|
setMode,
|
||||||
@@ -133,4 +166,6 @@ export const {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
setChartType,
|
||||||
|
setDashboardVisibleCards,
|
||||||
|
setDashboardUptimeSortOrder,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
@@ -77,6 +77,7 @@ export const getMonitorDefaults = (
|
|||||||
...base,
|
...base,
|
||||||
type: "docker",
|
type: "docker",
|
||||||
url: data?.url || "",
|
url: data?.url || "",
|
||||||
|
secret: data?.secret || "",
|
||||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||||
dockerTlsCa: data?.dockerTlsCa || "",
|
dockerTlsCa: data?.dockerTlsCa || "",
|
||||||
dockerTlsCert: data?.dockerTlsCert || "",
|
dockerTlsCert: data?.dockerTlsCert || "",
|
||||||
@@ -84,6 +85,8 @@ export const getMonitorDefaults = (
|
|||||||
dockerTlsKey: "",
|
dockerTlsKey: "",
|
||||||
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
|
||||||
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
|
||||||
|
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
|
||||||
|
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "game":
|
case "game":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
|
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
|
||||||
import { useDelete, useGet } from "@/Hooks/UseApi";
|
import { useDelete, useGet } from "@/Hooks/UseApi";
|
||||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MonitorType,
|
type MonitorType,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
@@ -24,10 +25,12 @@ interface MonitorListConfig {
|
|||||||
|
|
||||||
export const useMonitorListController = (config: MonitorListConfig) => {
|
export const useMonitorListController = (config: MonitorListConfig) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const rowsPerPage = useSelector(
|
const tableState = useSelector(
|
||||||
(state: RootState) =>
|
(state: RootState) => state.ui?.[config.rowsPerPageTable]
|
||||||
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
|
|
||||||
);
|
);
|
||||||
|
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
|
||||||
|
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
|
||||||
|
const sortOrder = tableState?.sortOrder ?? "asc";
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
|
||||||
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [page, setPage] = useState<number>(0);
|
const [page, setPage] = useState<number>(0);
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
|
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
|
|
||||||
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSort = (field: string, order: SortOrder) => {
|
||||||
|
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
// Check for active filters
|
// Check for active filters
|
||||||
const hasActiveFilters = Boolean(
|
const hasActiveFilters = Boolean(
|
||||||
selectedTypes.length > 0 ||
|
selectedTypes.length > 0 ||
|
||||||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
|
|||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
handleSetRowsPerPage,
|
handleSetRowsPerPage,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
|
|
||||||
// Delete
|
// Delete
|
||||||
selectedMonitor,
|
selectedMonitor,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
PageSpeedStrategies,
|
PageSpeedStrategies,
|
||||||
DnsRecordTypes,
|
DnsRecordTypes,
|
||||||
MonitorIntervalOptions,
|
MonitorIntervalOptions,
|
||||||
|
MIN_MONITOR_INTERVAL_MS,
|
||||||
DefaultMonitorMatchMethod,
|
DefaultMonitorMatchMethod,
|
||||||
MonitorMatchMethods,
|
MonitorMatchMethods,
|
||||||
GeoCheckIntervalOptions,
|
GeoCheckIntervalOptions,
|
||||||
@@ -50,6 +51,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
|
|||||||
import {
|
import {
|
||||||
stepFieldsFor,
|
stepFieldsFor,
|
||||||
monitorStepCount,
|
monitorStepCount,
|
||||||
|
isCaptureDockerUrl,
|
||||||
isDockerTlsUrl,
|
isDockerTlsUrl,
|
||||||
type MonitorFormData,
|
type MonitorFormData,
|
||||||
} from "@/Validation/monitor";
|
} from "@/Validation/monitor";
|
||||||
@@ -58,9 +60,14 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
|
|||||||
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
|
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
|
||||||
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
|
||||||
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
|
||||||
|
import {
|
||||||
|
FormAutocompleteField,
|
||||||
|
type AutocompleteOption,
|
||||||
|
} from "@/Components/inputs/forms/FormAutoCompleteField";
|
||||||
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
|
||||||
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
|
||||||
import type { ProxyResponse } from "@/Types/Proxy";
|
import type { ProxyResponse } from "@/Types/Proxy";
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
const httpMethodOptions = HttpMethods.map((method) => ({
|
const httpMethodOptions = HttpMethods.map((method) => ({
|
||||||
value: method,
|
value: method,
|
||||||
@@ -75,6 +82,40 @@ const maskedInputSx = {
|
|||||||
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
|
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface IntervalOption extends AutocompleteOption {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
|
||||||
|
|
||||||
|
const toIntervalOption = (
|
||||||
|
intervalMs: number,
|
||||||
|
locale: string | undefined
|
||||||
|
): IntervalOption => ({
|
||||||
|
id: intervalMs,
|
||||||
|
name: formatDuration(intervalMs, { long: true, locale }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterIntervalOptions = (
|
||||||
|
options: IntervalOption[],
|
||||||
|
inputValue: string,
|
||||||
|
locale: string | undefined
|
||||||
|
): IntervalOption[] => {
|
||||||
|
const input = inputValue.trim();
|
||||||
|
if (input === "" || options.some((option) => option.name === input)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(input);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
|
||||||
|
.filter(Number.isSafeInteger)
|
||||||
|
.map((ms) => toIntervalOption(ms, locale));
|
||||||
|
};
|
||||||
|
|
||||||
interface GeneralSettingsConfig {
|
interface GeneralSettingsConfig {
|
||||||
urlLabel: string;
|
urlLabel: string;
|
||||||
urlPlaceholder: string;
|
urlPlaceholder: string;
|
||||||
@@ -252,7 +293,7 @@ const getGeneralSettingsConfig = (
|
|||||||
|
|
||||||
const CreateMonitorPage = () => {
|
const CreateMonitorPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { monitorId } = useParams();
|
const { monitorId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -310,10 +351,12 @@ const CreateMonitorPage = () => {
|
|||||||
|
|
||||||
const watchedType = watch("type") as MonitorType;
|
const watchedType = watch("type") as MonitorType;
|
||||||
const watchedUrl = watch("url") as string;
|
const watchedUrl = watch("url") as string;
|
||||||
|
const isCaptureDocker = watchedType === "docker" && isCaptureDockerUrl(watchedUrl);
|
||||||
const watchedMethod = watch("method") as HttpMethod | undefined;
|
const watchedMethod = watch("method") as HttpMethod | undefined;
|
||||||
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
|
||||||
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
|
||||||
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
|
||||||
|
const watchedInterval = watch("interval");
|
||||||
|
|
||||||
// Steps without an advanced section drop it, so the last step is the form's.
|
// Steps without an advanced section drop it, so the last step is the form's.
|
||||||
const totalSteps = monitorStepCount(watchedType);
|
const totalSteps = monitorStepCount(watchedType);
|
||||||
@@ -339,6 +382,13 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
}, [watchedType, showTypeSelector, reset, clearErrors]);
|
}, [watchedType, showTypeSelector, reset, clearErrors]);
|
||||||
|
|
||||||
|
// The logs switch is hidden for Capture hosts, so a value left over from a socket host must not be submitted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCaptureDocker) {
|
||||||
|
setValue("dockerLogsEnabled", false);
|
||||||
|
}
|
||||||
|
}, [isCaptureDocker, setValue]);
|
||||||
|
|
||||||
const generalSettingsConfig = useMemo(
|
const generalSettingsConfig = useMemo(
|
||||||
() => getGeneralSettingsConfig(watchedType, t),
|
() => getGeneralSettingsConfig(watchedType, t),
|
||||||
[watchedType, t]
|
[watchedType, t]
|
||||||
@@ -471,16 +521,18 @@ const CreateMonitorPage = () => {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const intervalOptions = useMemo(
|
const intervalOptions = useMemo(() => {
|
||||||
() =>
|
const options = MonitorIntervalOptions.map(({ value }) =>
|
||||||
MonitorIntervalOptions.map((option) => ({
|
toIntervalOption(value, i18n.resolvedLanguage)
|
||||||
value: option.value,
|
|
||||||
label: t(
|
|
||||||
`pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
[t]
|
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
typeof watchedInterval === "number" &&
|
||||||
|
!options.some((option) => option.id === watchedInterval)
|
||||||
|
) {
|
||||||
|
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage));
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [watchedInterval, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
const matchMethodOptions = useMemo(
|
const matchMethodOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -565,7 +617,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(0) && (
|
{showStep(0) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.general.title")}
|
title={t("pages.createMonitor.form.general.title")}
|
||||||
@@ -666,8 +717,8 @@ const CreateMonitorPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secret field - only for hardware type */}
|
{/* Capture API secret - used by hardware and Capture-backed Docker monitors */}
|
||||||
{generalSettingsConfig.showSecret && (
|
{(generalSettingsConfig.showSecret || isCaptureDocker) && (
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name="secret"
|
name="secret"
|
||||||
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
|
fieldLabel={t("pages.createMonitor.form.general.option.secret.label")}
|
||||||
@@ -676,6 +727,12 @@ const CreateMonitorPage = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isCaptureDocker && (
|
||||||
|
<FormSwitchField
|
||||||
|
name="ignoreTlsErrors"
|
||||||
|
label={t("pages.createMonitor.form.ignoreTls.option.tls.label")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{generalSettingsConfig.showDnsServer && (
|
{generalSettingsConfig.showDnsServer && (
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name="dnsServer"
|
name="dnsServer"
|
||||||
@@ -700,7 +757,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.dockerTls.title")}
|
title={t("pages.createMonitor.form.dockerTls.title")}
|
||||||
@@ -758,23 +814,26 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.frequency.title")}
|
title={t("pages.createMonitor.form.frequency.title")}
|
||||||
subtitle={t("pages.createMonitor.form.frequency.description")}
|
subtitle={t("pages.createMonitor.form.frequency.description")}
|
||||||
rightContent={
|
rightContent={
|
||||||
<FormSelectField
|
<FormAutocompleteField
|
||||||
name="interval"
|
name="interval"
|
||||||
fieldLabel={t(
|
fieldLabel={t(
|
||||||
"pages.createMonitor.form.frequency.option.frequency.label"
|
"pages.createMonitor.form.frequency.option.frequency.label"
|
||||||
)}
|
)}
|
||||||
options={intervalOptions}
|
options={intervalOptions}
|
||||||
|
filterOptions={(options, { inputValue }) =>
|
||||||
|
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
|
||||||
|
}
|
||||||
|
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
|
||||||
|
disableClearable
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert Thresholds - only for hardware type */}
|
{/* Alert Thresholds - only for hardware type */}
|
||||||
{watchedType === "hardware" && showStep(1) && (
|
{watchedType === "hardware" && showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
@@ -830,7 +889,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.incidents.title")}
|
title={t("pages.createMonitor.form.incidents.title")}
|
||||||
@@ -857,7 +915,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.notifications.title")}
|
title={t("pages.createMonitor.form.notifications.title")}
|
||||||
@@ -870,7 +927,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.tags.title")}
|
title={t("pages.createMonitor.form.tags.title")}
|
||||||
@@ -897,8 +953,7 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
|
||||||
{showStep(1) && watchedType === "docker" && (
|
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.dockerLogs.title")}
|
title={t("pages.createMonitor.form.dockerLogs.title")}
|
||||||
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
subtitle={t("pages.createMonitor.form.dockerLogs.description")}
|
||||||
@@ -910,7 +965,28 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showStep(1) && watchedType === "docker" && (
|
||||||
|
<ConfigBox
|
||||||
|
title={t("pages.createMonitor.form.dockerAlerts.title")}
|
||||||
|
subtitle={t("pages.createMonitor.form.dockerAlerts.description")}
|
||||||
|
rightContent={
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnStopped"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onStopped.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name="dockerAlertOnUnhealthy"
|
||||||
|
label={t(
|
||||||
|
"pages.createMonitor.form.dockerAlerts.option.onUnhealthy.label"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showStep(2) &&
|
{showStep(2) &&
|
||||||
(watchedType === "http" ||
|
(watchedType === "http" ||
|
||||||
watchedType === "grpc" ||
|
watchedType === "grpc" ||
|
||||||
@@ -926,7 +1002,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && watchedType === "http" && (
|
{showStep(2) && watchedType === "http" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.advanced.title")}
|
title={t("pages.createMonitor.form.advanced.title")}
|
||||||
@@ -1026,7 +1101,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(2) && supportsGeoCheck(watchedType) && (
|
{showStep(2) && supportsGeoCheck(watchedType) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.geoChecks.title")}
|
title={t("pages.createMonitor.form.geoChecks.title")}
|
||||||
@@ -1059,7 +1133,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
justifyContent={!isEditMode ? "space-between" : "flex-end"}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
interface DashboardCardProps {
|
||||||
|
title: string;
|
||||||
|
topRight?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardCard = ({ title, topRight, children }: DashboardCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bgcolor={theme.palette.background.paper}
|
||||||
|
border={1}
|
||||||
|
borderColor={theme.palette.divider}
|
||||||
|
borderRadius={`${theme.shape.borderRadius}px`}
|
||||||
|
padding={theme.spacing(LAYOUT.MD)}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
gap={theme.spacing(LAYOUT.SM)}
|
||||||
|
height="100%"
|
||||||
|
boxSizing="border-box"
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{topRight && (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.s}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{topRight}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Checkbox, Dialog } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
|
interface EditCardsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
visibleCards: Set<DashboardCardKey>;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (visibleCards: Set<DashboardCardKey>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditCardsModal = ({
|
||||||
|
open,
|
||||||
|
visibleCards,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: EditCardsModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
|
||||||
|
new Set(visibleCards)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCardsSelection(new Set(visibleCards));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: DashboardCardKey) => {
|
||||||
|
setCardsSelection((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedCardKeys = useMemo(
|
||||||
|
() =>
|
||||||
|
[...dashboardCardKeys].sort((a, b) =>
|
||||||
|
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
|
||||||
|
),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={t("pages.dashboard.editCardsModal.title")}
|
||||||
|
content={t("pages.dashboard.editCardsModal.description")}
|
||||||
|
onCancel={handleClose}
|
||||||
|
onConfirm={() => onSave(cardsSelection)}
|
||||||
|
confirmText={t("pages.dashboard.editCardsModal.done")}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.XXS)}>
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.editCardsModal.groups.uptime")}
|
||||||
|
</Typography>
|
||||||
|
{sortedCardKeys.map((key: DashboardCardKey) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={key}
|
||||||
|
label={t(`pages.dashboard.cards.${key}`)}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={cardsSelection.has(key)}
|
||||||
|
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
|
||||||
|
onChange={() => handleToggle(key)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
fillRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressBar = ({ fillRatio }: ProgressBarProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const clamped = Math.min(1, Math.max(0, fillRatio));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
height={theme.spacing(LAYOUT.XS)}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.disabledBackground}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
height="100%"
|
||||||
|
width={`${clamped * 100}%`}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.primary.main}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
|
||||||
|
|
||||||
|
interface BarRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
fillRatio: number;
|
||||||
|
valueColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={theme.spacing(SPACING.LG)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={valueColor ?? theme.palette.primary.main}
|
||||||
|
fontWeight={500}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={fillRatio} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface CurrentlyDownCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.currentlyDown")}
|
||||||
|
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
|
||||||
|
>
|
||||||
|
{downMonitors.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.currentlyDown.empty")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{downMonitors.map((monitor) => (
|
||||||
|
<Stack
|
||||||
|
key={monitor.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={theme.spacing(LAYOUT.XS)}
|
||||||
|
>
|
||||||
|
<Dot color={theme.palette.error.main} />
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
overflow="hidden"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{monitor.name}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { MonitorsSummary } from "@/Types/Monitor";
|
||||||
|
|
||||||
|
interface MonitorStatusCardProps {
|
||||||
|
summary: MonitorsSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const up = summary?.upMonitors ?? 0;
|
||||||
|
const total = summary?.totalMonitors ?? 0;
|
||||||
|
const ratio = total > 0 ? up / total : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
|
||||||
|
{total === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(SPACING.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
gap={theme.spacing(SPACING.XS)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.xxl}
|
||||||
|
fontWeight={500}
|
||||||
|
color={theme.palette.text.primary}
|
||||||
|
>
|
||||||
|
{up}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.monitorStatus.ofUp", { total })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={ratio} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import type { MonitorTypeCount } from "@/Types/Monitor";
|
||||||
|
import { getMonitorTypeLabel } from "@/Types/Monitor";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
|
interface MonitorsByTypeCardProps {
|
||||||
|
monitorsByType: MonitorTypeCount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const maxCount = monitorsByType[0]?.count ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
|
||||||
|
{monitorsByType.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{monitorsByType.map(({ type, count }) => (
|
||||||
|
<BarRow
|
||||||
|
key={type}
|
||||||
|
label={getMonitorTypeLabel(type, t)}
|
||||||
|
value={String(count)}
|
||||||
|
fillRatio={count / maxCount}
|
||||||
|
valueColor={theme.palette.text.primary}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
|
import { Icon } from "@/Components/design-elements";
|
||||||
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
|
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
|
||||||
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import type { AppDispatch, RootState } from "@/store";
|
||||||
|
|
||||||
|
const TOP_N = 5;
|
||||||
|
|
||||||
|
interface UptimeCardProps {
|
||||||
|
monitors: Monitor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
|
||||||
|
|
||||||
|
const direction = sortOrder === "ascending" ? 1 : -1;
|
||||||
|
const ranked = monitors
|
||||||
|
.filter((monitor) => monitor.status !== "paused")
|
||||||
|
.filter(
|
||||||
|
(monitor): monitor is Monitor & { uptimePercentage: number } =>
|
||||||
|
typeof monitor.uptimePercentage === "number"
|
||||||
|
)
|
||||||
|
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
|
||||||
|
.slice(0, TOP_N);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
title={t("pages.dashboard.cards.uptime")}
|
||||||
|
topRight={
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
|
||||||
|
onClick={() =>
|
||||||
|
dispatch(
|
||||||
|
setDashboardUptimeSortOrder(
|
||||||
|
sortOrder === "ascending" ? "descending" : "ascending"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
|
||||||
|
size={16}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ranked.length === 0 ? (
|
||||||
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.disabled}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.noData")}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
{ranked.map((monitor) => {
|
||||||
|
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
|
||||||
|
const color = theme.palette[paletteKey].main;
|
||||||
|
return (
|
||||||
|
<BarRow
|
||||||
|
key={monitor.id}
|
||||||
|
label={monitor.name}
|
||||||
|
value={formatPercentage(monitor.uptimePercentage)}
|
||||||
|
fillRatio={monitor.uptimePercentage}
|
||||||
|
valueColor={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import { BasePage } from "@/Components/design-elements/BasePage";
|
||||||
|
import { Button } from "@/Components/inputs";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { useMonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
|
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
|
||||||
|
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
|
||||||
|
import type { RootState, AppDispatch } from "@/store";
|
||||||
|
|
||||||
|
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
|
||||||
|
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
|
||||||
|
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
|
||||||
|
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
||||||
|
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
||||||
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const visibleCardsList = useSelector(
|
||||||
|
(state: RootState) => state.ui.dashboardVisibleCards
|
||||||
|
);
|
||||||
|
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
|
||||||
|
|
||||||
|
const { monitors, summary } = useMonitorListController({
|
||||||
|
types: [...MonitorTypes],
|
||||||
|
checksLimit: 1,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
rowsPerPageTable: "dashboard",
|
||||||
|
rowsPerPageDefault: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
|
||||||
|
const countMap = new Map<MonitorTypeCount["type"], number>();
|
||||||
|
(monitors ?? []).forEach((m) =>
|
||||||
|
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
|
||||||
|
);
|
||||||
|
return [...countMap.entries()]
|
||||||
|
.map(([type, count]) => ({ type, count }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}, [monitors]);
|
||||||
|
|
||||||
|
const cardComponents: Record<DashboardCardKey, ReactNode> = {
|
||||||
|
monitorStatus: <MonitorStatusCard summary={summary} />,
|
||||||
|
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
|
||||||
|
uptime: <UptimeCard monitors={monitors ?? []} />,
|
||||||
|
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasePage headerKey="dashboard">
|
||||||
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.editCards")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
|
||||||
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
|
>
|
||||||
|
{dashboardCardKeys
|
||||||
|
.filter((key) => visibleCards.has(key))
|
||||||
|
.map((key) => (
|
||||||
|
<Fragment key={key}>{cardComponents[key]}</Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<EditCardsModal
|
||||||
|
open={editOpen}
|
||||||
|
visibleCards={visibleCards}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSave={(next) => {
|
||||||
|
dispatch(setDashboardVisibleCards([...next]));
|
||||||
|
setEditOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BasePage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
|
|||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { formatDuration } from "@/Utils/TimeUtils";
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
import { getDockerPalette } from "@/Utils/MonitorUtils";
|
import { getDockerPalette } from "@/Utils/MonitorUtils";
|
||||||
|
|
||||||
export const DockerContainerStatusBoxes = ({
|
export const DockerContainerStatusBoxes = ({
|
||||||
@@ -23,8 +24,9 @@ export const DockerContainerStatusBoxes = ({
|
|||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
|
|
||||||
const { state, restartCount, health } = container;
|
const { state, restartCount, health } = container;
|
||||||
const startedAt = container.startedAt ?? "0";
|
const runningFor = container.startedAt
|
||||||
const runningFor = Date.now() - new Date(startedAt).getTime();
|
? Date.now() - new Date(container.startedAt).getTime()
|
||||||
|
: undefined;
|
||||||
const palette = getDockerPalette(state);
|
const palette = getDockerPalette(state);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -39,11 +41,11 @@ export const DockerContainerStatusBoxes = ({
|
|||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.uptime")}
|
title={t("common.labels.uptime")}
|
||||||
subtitle={formatDuration(runningFor)}
|
subtitle={runningFor === undefined ? PLACEHOLDER : formatDuration(runningFor)}
|
||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.restarts")}
|
title={t("common.labels.restarts")}
|
||||||
subtitle={String(restartCount ?? 0)}
|
subtitle={restartCount === undefined ? PLACEHOLDER : String(restartCount)}
|
||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("common.labels.health")}
|
title={t("common.labels.health")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { Theme } from "@mui/material";
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { DockerContainerStats } from "@/Types/Monitor";
|
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { BaseBox } from "@/Components/design-elements";
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
|
||||||
@@ -88,11 +88,17 @@ export const TabOverview = ({
|
|||||||
Ports
|
Ports
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack>
|
<Stack>
|
||||||
{dedupedPorts?.map((port) => {
|
{dedupedPorts.length === 0 && <Typography>{PLACEHOLDER}</Typography>}
|
||||||
|
{dedupedPorts.map((port) => {
|
||||||
|
const containerPort = `${port.privatePort}/${port.protocol}`;
|
||||||
|
const label =
|
||||||
|
port.publicPort === undefined
|
||||||
|
? containerPort
|
||||||
|
: `${port.hostIp ?? "0.0.0.0"}:${port.publicPort} -> ${containerPort}`;
|
||||||
return (
|
return (
|
||||||
<Typography
|
<Typography key={`${port.hostIp}/${port.publicPort}/${containerPort}`}>
|
||||||
key={`${port.publicPort}/${port.privatePort}`}
|
{label}
|
||||||
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -111,6 +117,7 @@ export const TabOverview = ({
|
|||||||
Volumes
|
Volumes
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
|
||||||
{mounts?.map((mount) => {
|
{mounts?.map((mount) => {
|
||||||
return (
|
return (
|
||||||
<Typography key={`${mount.source}/${mount.destination}`}>
|
<Typography key={`${mount.source}/${mount.destination}`}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Header } from "@/Components/design-elements/Table";
|
|||||||
import type { DockerContainerInfo } from "@/Types/Check";
|
import type { DockerContainerInfo } from "@/Types/Check";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface DockerContainersTableProps {
|
interface DockerContainersTableProps {
|
||||||
monitorId: string | undefined;
|
monitorId: string | undefined;
|
||||||
@@ -91,7 +91,7 @@ export const DockerContainersTable = ({
|
|||||||
id: "restarts",
|
id: "restarts",
|
||||||
content: t("common.labels.restarts"),
|
content: t("common.labels.restarts"),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
return <Typography>{row.restartCount}</Typography>;
|
return <Typography>{row.restartCount ?? PLACEHOLDER}</Typography>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { DockerContainerSummary } from "@/Types/Check";
|
import type { DockerContainerSummary } from "@/Types/Check";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const DockerMonitorsPage = () => {
|
|||||||
controller={c}
|
controller={c}
|
||||||
bulkActions
|
bulkActions
|
||||||
showTypeFilter={false}
|
showTypeFilter={false}
|
||||||
|
summaryProps={{ showBreached: true }}
|
||||||
>
|
>
|
||||||
<DockerMonitorsTable
|
<DockerMonitorsTable
|
||||||
monitors={c.monitors || []}
|
monitors={c.monitors || []}
|
||||||
@@ -30,9 +31,8 @@ const DockerMonitorsPage = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
selectedRows={c.selectedRows}
|
selectedRows={c.selectedRows}
|
||||||
onSelectionChange={c.setSelectedRows}
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
interface InfraMonitorsTableProps {
|
interface InfraMonitorsTableProps {
|
||||||
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
selectedRows={c.selectedRows}
|
selectedRows={c.selectedRows}
|
||||||
onSelectionChange={c.setSelectedRows}
|
onSelectionChange={c.setSelectedRows}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { DetailGauge } from "@/Components/design-elements";
|
import { DetailGauge } from "@/Components/design-elements";
|
||||||
|
|
||||||
import { getPercentage, formatPercentageFromWhole } from "@/Utils/FormatUtils";
|
import { getPercentage, formatPercentageFromWhole, PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
@@ -11,7 +11,6 @@ interface StatGaugesProps {
|
|||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "—";
|
|
||||||
|
|
||||||
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
|
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { useTheme } from "@mui/material";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Diagnostics } from "@/Types/Diagnostics";
|
import type { Diagnostics } from "@/Types/Diagnostics";
|
||||||
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
|
||||||
|
import { PLACEHOLDER } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
diagnostics: Diagnostics | null;
|
diagnostics: Diagnostics | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLACEHOLDER = "—";
|
|
||||||
|
|
||||||
export const Stats = ({ diagnostics }: StatsProps) => {
|
export const Stats = ({ diagnostics }: StatsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
const LogsPage = () => {
|
const LogsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<number>(1);
|
const [activeTab, setActiveTab] = useState<number>(0);
|
||||||
return (
|
return (
|
||||||
<BasePage headerKey="logs">
|
<BasePage headerKey="logs">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ColoredLabel,
|
ColoredLabel,
|
||||||
} from "@/Components/design-elements";
|
} from "@/Components/design-elements";
|
||||||
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
|
||||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
|
|||||||
|
|
||||||
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
|
|
||||||
export const PageSpeedMonitorsTable = ({
|
export const PageSpeedMonitorsTable = ({
|
||||||
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
refetch: Function;
|
refetch: Function;
|
||||||
setSelectedMonitor: Function;
|
setSelectedMonitor: Function;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
|
|||||||
const handleSort = (e: any, field: string) => {
|
const handleSort = (e: any, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
const getActions = (monitor: Monitor): ActionMenuItem[] => {
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
|
|||||||
setPage={c.setPage}
|
setPage={c.setPage}
|
||||||
rowsPerPage={c.rowsPerPage}
|
rowsPerPage={c.rowsPerPage}
|
||||||
sortField={c.sortField}
|
sortField={c.sortField}
|
||||||
setSortField={c.setSortField}
|
|
||||||
sortOrder={c.sortOrder}
|
sortOrder={c.sortOrder}
|
||||||
setSortOrder={c.setSortOrder}
|
setSort={c.setSort}
|
||||||
setRowsPerPage={c.handleSetRowsPerPage}
|
setRowsPerPage={c.handleSetRowsPerPage}
|
||||||
/>
|
/>
|
||||||
</MonitorListPage>
|
</MonitorListPage>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||||
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||||
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||||
|
import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal";
|
||||||
import {
|
import {
|
||||||
ThemedInfrastructure,
|
ThemedInfrastructure,
|
||||||
type GaugeFillLevel,
|
type GaugeFillLevel,
|
||||||
@@ -116,6 +117,8 @@ export const BaseStatusPage = ({
|
|||||||
[config, tokens, mode]
|
[config, tokens, mode]
|
||||||
);
|
);
|
||||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||||
|
const [selectedMonitorId, setSelectedMonitorId] = useState<string | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
||||||
const logoSrc = statusPage.logo?.data
|
const logoSrc = statusPage.logo?.data
|
||||||
@@ -306,6 +309,10 @@ export const BaseStatusPage = ({
|
|||||||
cells={cells}
|
cells={cells}
|
||||||
containerSx={styles.heatmap}
|
containerSx={styles.heatmap}
|
||||||
cellSx={styles.heatmapCell}
|
cellSx={styles.heatmapCell}
|
||||||
|
onCellClick={(date) => {
|
||||||
|
setSelectedMonitorId(monitor.id);
|
||||||
|
setSelectedDate(date);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThemedHistogram
|
<ThemedHistogram
|
||||||
@@ -313,6 +320,10 @@ export const BaseStatusPage = ({
|
|||||||
containerSx={styles.histogram}
|
containerSx={styles.histogram}
|
||||||
barSx={styles.bar}
|
barSx={styles.bar}
|
||||||
statsSx={styles.chartStats}
|
statsSx={styles.chartStats}
|
||||||
|
onCellClick={(date) => {
|
||||||
|
setSelectedMonitorId(monitor.id);
|
||||||
|
setSelectedDate(date);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -333,6 +344,16 @@ export const BaseStatusPage = ({
|
|||||||
Checkmate
|
Checkmate
|
||||||
</a>
|
</a>
|
||||||
</Box>
|
</Box>
|
||||||
|
<IncidentModal
|
||||||
|
url={statusPage.url}
|
||||||
|
monitorId={selectedMonitorId}
|
||||||
|
date={selectedDate}
|
||||||
|
timezone={bucketTimezone}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedMonitorId(null);
|
||||||
|
setSelectedDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -25,6 +25,7 @@ export interface ChartCell {
|
|||||||
responseTime: number; // 0 for empty cells
|
responseTime: number; // 0 for empty cells
|
||||||
tooltip: React.ReactNode | null;
|
tooltip: React.ReactNode | null;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
|
date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_DOWN_THRESHOLD = 0.5;
|
const DAY_DOWN_THRESHOLD = 0.5;
|
||||||
@@ -61,7 +62,7 @@ export const classifyResponseTime = (
|
|||||||
return "fast";
|
return "fast";
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyCell = (key: string): ChartCell => ({
|
const emptyCell = (key: string, date: string | null = null): ChartCell => ({
|
||||||
key,
|
key,
|
||||||
barKind: "empty",
|
barKind: "empty",
|
||||||
heatKind: "empty",
|
heatKind: "empty",
|
||||||
@@ -70,6 +71,7 @@ const emptyCell = (key: string): ChartCell => ({
|
|||||||
tooltip: null,
|
tooltip: null,
|
||||||
ariaLabel: "",
|
ariaLabel: "",
|
||||||
severity: 0,
|
severity: 0,
|
||||||
|
date,
|
||||||
});
|
});
|
||||||
|
|
||||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||||
@@ -92,6 +94,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
|||||||
tooltip: <ThemedChartTooltip check={check} />,
|
tooltip: <ThemedChartTooltip check={check} />,
|
||||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||||
severity: 0,
|
severity: 0,
|
||||||
|
date: null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return padCells(cells, MAX_RECENT_CHECKS);
|
return padCells(cells, MAX_RECENT_CHECKS);
|
||||||
@@ -108,7 +111,7 @@ export const dailyBucketsToCells = (
|
|||||||
const enumerated = enumerateDays(days, bucketTimezone);
|
const enumerated = enumerateDays(days, bucketTimezone);
|
||||||
return enumerated.map((date) => {
|
return enumerated.map((date) => {
|
||||||
const bucket = byDate.get(date);
|
const bucket = byDate.get(date);
|
||||||
if (!bucket) return emptyCell(`day-${date}`);
|
if (!bucket) return emptyCell(`day-${date}`, date);
|
||||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||||
|
|
||||||
const barKind = classifyDay(bucket, upRatio);
|
const barKind = classifyDay(bucket, upRatio);
|
||||||
@@ -132,6 +135,7 @@ export const dailyBucketsToCells = (
|
|||||||
date,
|
date,
|
||||||
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
|
||||||
}),
|
}),
|
||||||
|
date,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import { X as XIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
|
import type { Incident } from "@/Types/Incident";
|
||||||
|
interface IncidentModalProps {
|
||||||
|
url: string;
|
||||||
|
monitorId: string | null;
|
||||||
|
date: string | null;
|
||||||
|
timezone: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncidentModal = ({
|
||||||
|
url,
|
||||||
|
monitorId,
|
||||||
|
date,
|
||||||
|
timezone,
|
||||||
|
onClose,
|
||||||
|
}: IncidentModalProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = Boolean(monitorId && date);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useGet<{ incidents: Incident[] }>(
|
||||||
|
open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null,
|
||||||
|
{},
|
||||||
|
{ revalidateOnFocus: false, keepPreviousData: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.modalTitle", { date })} ({timezone})
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
size="small"
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography mt={SPACING.SM}>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.loading")}
|
||||||
|
</Typography>
|
||||||
|
) : error ? (
|
||||||
|
<Typography
|
||||||
|
mt={SPACING.SM}
|
||||||
|
color={theme.palette.error.main}
|
||||||
|
>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.errorLoading")}
|
||||||
|
</Typography>
|
||||||
|
) : data?.incidents && data.incidents.length > 0 ? (
|
||||||
|
<Stack
|
||||||
|
gap={SPACING.LG}
|
||||||
|
mt={SPACING.SM}
|
||||||
|
>
|
||||||
|
{data.incidents.map((incident) => (
|
||||||
|
<Stack
|
||||||
|
key={incident.id}
|
||||||
|
p={SPACING.LG}
|
||||||
|
border={1}
|
||||||
|
borderColor="divider"
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{incident.message ||
|
||||||
|
(incident.statusCode ? `Error ${incident.statusCode}` : "Incident")}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(incident.startTime, "h:mm A", timezone)} -{" "}
|
||||||
|
{incident.endTime
|
||||||
|
? formatDateWithTz(incident.endTime, "h:mm A", timezone)
|
||||||
|
: t("pages.statusPages.monitorsList.incidents.ongoing")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography mt={SPACING.SM}>
|
||||||
|
{t("pages.statusPages.monitorsList.incidents.noIncidents")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ import Tooltip from "@mui/material/Tooltip";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ChartCell,
|
ChartCell,
|
||||||
HeatCellKind,
|
HeatCellKind,
|
||||||
@@ -11,17 +13,15 @@ interface Props {
|
|||||||
cells: ChartCell[];
|
cells: ChartCell[];
|
||||||
containerSx: SxProps<Theme>;
|
containerSx: SxProps<Theme>;
|
||||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||||
|
onCellClick?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
|
||||||
>
|
>
|
||||||
@@ -30,7 +30,15 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={cell.key}
|
||||||
sx={cellSx("empty")}
|
sx={mergeSx(
|
||||||
|
cellSx("empty"),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,8 +50,16 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={cellSx(cell.heatKind, cell.severity)}
|
sx={mergeSx(
|
||||||
|
cellSx(cell.heatKind, cell.severity),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={cell.ariaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Stack from "@mui/material/Stack";
|
|||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMs } from "@/Utils/TimeUtils";
|
import { formatMs } from "@/Utils/TimeUtils";
|
||||||
|
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BarKind,
|
BarKind,
|
||||||
ChartCell,
|
ChartCell,
|
||||||
@@ -16,6 +18,7 @@ interface Props {
|
|||||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||||
statsSx: SxProps<Theme>;
|
statsSx: SxProps<Theme>;
|
||||||
statsGap?: number;
|
statsGap?: number;
|
||||||
|
onCellClick?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemedHistogram = ({
|
export const ThemedHistogram = ({
|
||||||
@@ -24,6 +27,7 @@ export const ThemedHistogram = ({
|
|||||||
barSx,
|
barSx,
|
||||||
statsSx,
|
statsSx,
|
||||||
statsGap = 1,
|
statsGap = 1,
|
||||||
|
onCellClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -39,17 +43,22 @@ export const ThemedHistogram = ({
|
|||||||
return (
|
return (
|
||||||
<Stack gap={statsGap}>
|
<Stack gap={statsGap}>
|
||||||
<Box
|
<Box
|
||||||
sx={[
|
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
|
||||||
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
|
|
||||||
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{cells.map((cell) => {
|
{cells.map((cell) => {
|
||||||
if (cell.barKind === "empty") {
|
if (cell.barKind === "empty") {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={cell.key}
|
key={cell.key}
|
||||||
sx={barSx("empty", cell.heightPct)}
|
sx={mergeSx(
|
||||||
|
barSx("empty", cell.heightPct),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,8 +70,16 @@ export const ThemedHistogram = ({
|
|||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
sx={mergeSx(
|
||||||
|
barSx(cell.barKind, cell.heightPct, cell.severity),
|
||||||
|
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
|
||||||
|
)}
|
||||||
aria-label={cell.ariaLabel}
|
aria-label={cell.ariaLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (cell.date && onCellClick) {
|
||||||
|
onCellClick(cell.date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export const mergeSx = (
|
||||||
|
...styles: Array<SxProps<Theme> | false | null | undefined>
|
||||||
|
): SxProps<Theme> => {
|
||||||
|
const result: SxProps<Theme>[] = [];
|
||||||
|
for (const style of styles) {
|
||||||
|
if (!style) continue;
|
||||||
|
if (Array.isArray(style)) {
|
||||||
|
result.push(...(style.filter(Boolean) as SxProps<Theme>[]));
|
||||||
|
} else {
|
||||||
|
result.push(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as SxProps<Theme>;
|
||||||
|
};
|
||||||
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { Checkbox } from "@/Components/inputs";
|
import { Checkbox } from "@/Components/inputs";
|
||||||
import type { Tag } from "@/Types/Tag";
|
import type { Tag } from "@/Types/Tag";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import { SPACING } from "@/Utils/Theme/constants";
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
|
||||||
import { formatPercentage } from "@/Utils/FormatUtils";
|
import { formatPercentage } from "@/Utils/FormatUtils";
|
||||||
|
|
||||||
interface MonitorTableProps {
|
interface MonitorTableProps {
|
||||||
@@ -34,9 +35,8 @@ interface MonitorTableProps {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||||
sortField: string;
|
sortField: string;
|
||||||
setSortField: (field: string) => void;
|
sortOrder: SortOrder;
|
||||||
sortOrder: "asc" | "desc";
|
setSort: (field: string, order: SortOrder) => void;
|
||||||
setSortOrder: (order: "asc" | "desc") => void;
|
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
page: number;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
@@ -52,9 +52,8 @@ export const MonitorTable = ({
|
|||||||
refetch,
|
refetch,
|
||||||
setSelectedMonitor,
|
setSelectedMonitor,
|
||||||
sortField,
|
sortField,
|
||||||
setSortField,
|
|
||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSort,
|
||||||
count,
|
count,
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
@@ -91,14 +90,8 @@ export const MonitorTable = ({
|
|||||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
|
||||||
const newOrder = sortOrder === "asc" ? "desc" : "asc";
|
setSort(nextSort.field, nextSort.order);
|
||||||
setSortOrder(newOrder);
|
|
||||||
} else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortOrder("asc");
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
const isBrowserOpenable = (monitor: Monitor): boolean => {
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
|
|||||||
setPage={monitorListController.setPage}
|
setPage={monitorListController.setPage}
|
||||||
rowsPerPage={monitorListController.rowsPerPage}
|
rowsPerPage={monitorListController.rowsPerPage}
|
||||||
sortField={monitorListController.sortField}
|
sortField={monitorListController.sortField}
|
||||||
setSortField={monitorListController.setSortField}
|
|
||||||
sortOrder={monitorListController.sortOrder}
|
sortOrder={monitorListController.sortOrder}
|
||||||
setSortOrder={monitorListController.setSortOrder}
|
setSort={monitorListController.setSort}
|
||||||
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
setRowsPerPage={monitorListController.handleSetRowsPerPage}
|
||||||
selectedRows={monitorListController.selectedRows}
|
selectedRows={monitorListController.selectedRows}
|
||||||
onSelectionChange={monitorListController.setSelectedRows}
|
onSelectionChange={monitorListController.setSelectedRows}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
|
|||||||
import AuthForgotPassword from "@/Pages/Auth/Recovery";
|
import AuthForgotPassword from "@/Pages/Auth/Recovery";
|
||||||
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
|
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
import Dashboard from "@/Pages/Dashboard";
|
||||||
|
|
||||||
// Uptime
|
// Uptime
|
||||||
import Uptime from "@/Pages/Uptime/Monitors";
|
import Uptime from "@/Pages/Uptime/Monitors";
|
||||||
import UptimeDetails from "@/Pages/Uptime/Details";
|
import UptimeDetails from "@/Pages/Uptime/Details";
|
||||||
@@ -92,6 +95,10 @@ const Routes = () => {
|
|||||||
path="/"
|
path="/"
|
||||||
element={<Navigate to="/uptime" />}
|
element={<Navigate to="/uptime" />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="dashboard"
|
||||||
|
element={<Dashboard />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/uptime"
|
path="/uptime"
|
||||||
element={<Uptime />}
|
element={<Uptime />}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const dashboardCardKeys = [
|
||||||
|
"monitorStatus",
|
||||||
|
"currentlyDown",
|
||||||
|
"uptime",
|
||||||
|
"monitorsByType",
|
||||||
|
] as const;
|
||||||
|
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
|
||||||
|
|
||||||
|
export const dashboardSortOrders = ["ascending", "descending"] as const;
|
||||||
|
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
|
||||||
@@ -60,6 +60,15 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
|
|||||||
pagespeed: "optionPagespeed",
|
pagespeed: "optionPagespeed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMonitorTypeLabel = (
|
||||||
|
type: MonitorType,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string => {
|
||||||
|
const monitorLabel = monitorTypeLabelKey[type];
|
||||||
|
return monitorLabel ? t(`pages.common.monitors.monitorTypes.${monitorLabel}`) : type;
|
||||||
|
};
|
||||||
|
export const MIN_MONITOR_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
export const MonitorIntervalOptions = [
|
export const MonitorIntervalOptions = [
|
||||||
{ value: 15000, labelKey: "fifteenSeconds" },
|
{ value: 15000, labelKey: "fifteenSeconds" },
|
||||||
{ value: 30000, labelKey: "thirtySeconds" },
|
{ value: 30000, labelKey: "thirtySeconds" },
|
||||||
@@ -160,6 +169,8 @@ export interface Monitor {
|
|||||||
geoCheckLocations?: GeoContinent[];
|
geoCheckLocations?: GeoContinent[];
|
||||||
geoCheckInterval?: number;
|
geoCheckInterval?: number;
|
||||||
dockerLogsEnabled?: boolean;
|
dockerLogsEnabled?: boolean;
|
||||||
|
dockerAlertOnStopped?: boolean;
|
||||||
|
dockerAlertOnUnhealthy?: boolean;
|
||||||
dockerTlsCa?: string;
|
dockerTlsCa?: string;
|
||||||
dockerTlsCert?: string;
|
dockerTlsCert?: string;
|
||||||
dockerTlsKeySet?: boolean;
|
dockerTlsKeySet?: boolean;
|
||||||
@@ -183,6 +194,11 @@ export interface MonitorsSummary {
|
|||||||
breachedMonitors: number;
|
breachedMonitors: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonitorTypeCount {
|
||||||
|
type: MonitorType;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MonitorsWithChecksResponse {
|
export interface MonitorsWithChecksResponse {
|
||||||
count: number;
|
count: number;
|
||||||
monitors: MonitorWithChecks[];
|
monitors: MonitorWithChecks[];
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
|
||||||
export type DateRange = (typeof DateRanges)[number];
|
export type DateRange = (typeof DateRanges)[number];
|
||||||
|
|
||||||
|
export const SortOrders = ["asc", "desc"] as const;
|
||||||
|
export type SortOrder = (typeof SortOrders)[number];
|
||||||
@@ -47,3 +47,6 @@ export const getPercentage = (value: number, total: number) => {
|
|||||||
if (!value || !total) return 0;
|
if (!value || !total) return 0;
|
||||||
return (value / total) * 100;
|
return (value / total) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shown wherever a value is not available for the current data source.
|
||||||
|
export const PLACEHOLDER = "—";
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
|
||||||
|
import type { SortOrder } from "@/Types/Query";
|
||||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,16 @@ import {
|
|||||||
type DockerLogLine,
|
type DockerLogLine,
|
||||||
} from "@/Types/Check";
|
} from "@/Types/Check";
|
||||||
|
|
||||||
|
export const getNextMonitorSort = (
|
||||||
|
field: string,
|
||||||
|
sortField: string,
|
||||||
|
sortOrder: SortOrder
|
||||||
|
): { field: string; order: SortOrder } => {
|
||||||
|
if (field !== sortField) return { field, order: "asc" };
|
||||||
|
if (sortOrder === "asc") return { field, order: "desc" };
|
||||||
|
return { field: "", order: "asc" };
|
||||||
|
};
|
||||||
|
|
||||||
export const getMonitorPath = (type: MonitorType): string => {
|
export const getMonitorPath = (type: MonitorType): string => {
|
||||||
const pathMap: Record<MonitorType, string> = {
|
const pathMap: Record<MonitorType, string> = {
|
||||||
http: "uptime",
|
http: "uptime",
|
||||||
@@ -20,7 +31,7 @@ export const getMonitorPath = (type: MonitorType): string => {
|
|||||||
websocket: "uptime",
|
websocket: "uptime",
|
||||||
dns: "uptime",
|
dns: "uptime",
|
||||||
unknown: "uptime",
|
unknown: "uptime",
|
||||||
docker: "docker",
|
docker: "docker/host",
|
||||||
hardware: "infrastructure",
|
hardware: "infrastructure",
|
||||||
pagespeed: "pagespeed",
|
pagespeed: "pagespeed",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export const lightPalette = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
accent: colors.brandGreen,
|
accent: colors.brandGreen,
|
||||||
},
|
},
|
||||||
|
scrollbar: {
|
||||||
|
track: colors.rule200,
|
||||||
|
thumb: colors.brandGreen,
|
||||||
|
},
|
||||||
rowStatus: {
|
rowStatus: {
|
||||||
running: colors.green200,
|
running: colors.green200,
|
||||||
paused: colors.yellow200,
|
paused: colors.yellow200,
|
||||||
@@ -137,6 +141,10 @@ export const darkPalette = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
accent: colors.brandGreenLight,
|
accent: colors.brandGreenLight,
|
||||||
},
|
},
|
||||||
|
scrollbar: {
|
||||||
|
track: colors.ruleDark,
|
||||||
|
thumb: colors.brandGreen,
|
||||||
|
},
|
||||||
rowStatus: {
|
rowStatus: {
|
||||||
running: alpha(colors.brandGreen, 0.18),
|
running: alpha(colors.brandGreen, 0.18),
|
||||||
paused: alpha(colors.orange500, 0.18),
|
paused: alpha(colors.orange500, 0.18),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palet
|
|||||||
|
|
||||||
import type { Theme } from "@mui/material/styles";
|
import type { Theme } from "@mui/material/styles";
|
||||||
import type {} from "@mui/material/styles";
|
import type {} from "@mui/material/styles";
|
||||||
|
import { SCROLLBAR_SIZE } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
declare module "@mui/material/styles" {
|
declare module "@mui/material/styles" {
|
||||||
interface TypographyVariants {
|
interface TypographyVariants {
|
||||||
@@ -20,6 +21,7 @@ declare module "@mui/material/styles" {
|
|||||||
}
|
}
|
||||||
interface Palette {
|
interface Palette {
|
||||||
sidebar: { accent: string };
|
sidebar: { accent: string };
|
||||||
|
scrollbar: { track: string; thumb: string };
|
||||||
rowStatus: { running: string; paused: string };
|
rowStatus: { running: string; paused: string };
|
||||||
chart: {
|
chart: {
|
||||||
phases: {
|
phases: {
|
||||||
@@ -34,6 +36,7 @@ declare module "@mui/material/styles" {
|
|||||||
}
|
}
|
||||||
interface PaletteOptions {
|
interface PaletteOptions {
|
||||||
sidebar?: { accent: string };
|
sidebar?: { accent: string };
|
||||||
|
scrollbar?: { track: string; thumb: string };
|
||||||
rowStatus?: { running: string; paused: string };
|
rowStatus?: { running: string; paused: string };
|
||||||
chart?: {
|
chart?: {
|
||||||
phases: {
|
phases: {
|
||||||
@@ -112,6 +115,27 @@ export const theme = (mode: string, palette: any) =>
|
|||||||
},
|
},
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: (theme) => ({
|
||||||
|
"*": {
|
||||||
|
scrollbarWidth: "auto",
|
||||||
|
scrollbarColor: `${theme.palette.scrollbar.thumb} ${theme.palette.scrollbar.track}`,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar": {
|
||||||
|
width: SCROLLBAR_SIZE,
|
||||||
|
height: SCROLLBAR_SIZE,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar-track": {
|
||||||
|
background: theme.palette.scrollbar.track,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar-thumb": {
|
||||||
|
background: theme.palette.scrollbar.thumb,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
},
|
||||||
|
"*::-webkit-scrollbar-button": { display: "none" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
MuiTouchRipple: {
|
MuiTouchRipple: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const LAYOUT = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
|
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
|
||||||
|
export const SCROLLBAR_SIZE = 16;
|
||||||
export const HOVER = {
|
export const HOVER = {
|
||||||
DARKEN: 0.06, // This is a coefficient for darkening function
|
DARKEN: 0.06, // This is a coefficient for darkening function
|
||||||
ROW: 0.025, // Overlay alpha for hoverable rows and cards
|
ROW: 0.025, // Overlay alpha for hoverable rows and cards
|
||||||
|
|||||||
@@ -104,9 +104,52 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => {
|
|||||||
return formatted;
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => {
|
type DurationUnit = "day" | "hour" | "minute" | "second";
|
||||||
if (verbose) {
|
|
||||||
return prettyMilliseconds(ms, { verbose: true });
|
interface FormatDurationOptions {
|
||||||
|
long?: boolean;
|
||||||
|
relative?: boolean;
|
||||||
|
hasSpace?: boolean;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
|
||||||
|
{ unit: "day", milliseconds: MS_PER_DAY },
|
||||||
|
{ unit: "hour", milliseconds: MS_PER_HOUR },
|
||||||
|
{ unit: "minute", milliseconds: MS_PER_MINUTE },
|
||||||
|
{ unit: "second", milliseconds: MS_PER_SECOND },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
|
||||||
|
|
||||||
|
const getDurationUnit = (ms: number) =>
|
||||||
|
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
|
||||||
|
FALLBACK_DURATION_UNIT;
|
||||||
|
|
||||||
|
const getExactDurationUnit = (ms: number) =>
|
||||||
|
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
|
||||||
|
FALLBACK_DURATION_UNIT;
|
||||||
|
|
||||||
|
export const formatDuration = (
|
||||||
|
ms: number,
|
||||||
|
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
|
||||||
|
): string => {
|
||||||
|
if (relative) {
|
||||||
|
const { unit, milliseconds } = getDurationUnit(ms);
|
||||||
|
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
|
||||||
|
return new Intl.RelativeTimeFormat(locale, {
|
||||||
|
numeric: "always",
|
||||||
|
style: long ? "long" : "narrow",
|
||||||
|
}).format(-value, unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (long) {
|
||||||
|
const { unit, milliseconds } = getExactDurationUnit(ms);
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: "unit",
|
||||||
|
unit,
|
||||||
|
unitDisplay: "long",
|
||||||
|
}).format(ms / milliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatted = prettyMilliseconds(ms, {
|
const formatted = prettyMilliseconds(ms, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HttpMethods,
|
HttpMethods,
|
||||||
PageSpeedStrategies,
|
PageSpeedStrategies,
|
||||||
ProxyModes,
|
ProxyModes,
|
||||||
|
MIN_MONITOR_INTERVAL_MS,
|
||||||
type MonitorType,
|
type MonitorType,
|
||||||
} from "@/Types/Monitor";
|
} from "@/Types/Monitor";
|
||||||
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
|
||||||
@@ -27,7 +28,8 @@ const baseSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
interval: z
|
interval: z
|
||||||
.number()
|
.number()
|
||||||
.min(15000, "Interval must be at least 15 seconds")
|
.int("Interval must be a whole number of milliseconds")
|
||||||
|
.min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
|
||||||
.register(monitorStepRegistry, { step: 1 }),
|
.register(monitorStepRegistry, { step: 1 }),
|
||||||
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
||||||
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
|
||||||
@@ -108,23 +110,40 @@ const portSchema = baseSchema.extend({
|
|||||||
.max(65535, "Port must be at most 65535"),
|
.max(65535, "Port must be at most 65535"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
|
// Docker monitor schema. The URL forms mirror the Docker source helpers in
|
||||||
// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts.
|
// server/src/utils/dockerHost.ts.
|
||||||
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
|
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
|
||||||
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
|
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
|
||||||
|
|
||||||
export const isDockerTlsUrl = (url: string | undefined): boolean =>
|
export const isDockerTlsUrl = (url: string | undefined): boolean =>
|
||||||
dockerTlsUrlRegex.test(url?.trim() ?? "");
|
dockerTlsUrlRegex.test(url?.trim() ?? "");
|
||||||
|
|
||||||
|
export const isCaptureDockerUrl = (url: string | undefined): boolean => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url?.trim() ?? "");
|
||||||
|
const pathname = parsed.pathname.replace(/\/+$/, "");
|
||||||
|
return (
|
||||||
|
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
||||||
|
pathname.endsWith("/metrics/docker")
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const dockerSchema = baseSchema.extend({
|
const dockerSchema = baseSchema.extend({
|
||||||
type: z.literal("docker"),
|
type: z.literal("docker"),
|
||||||
url: z
|
url: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Docker host URL is required")
|
.min(1, "Docker host URL is required")
|
||||||
.refine(
|
.refine(
|
||||||
(url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url),
|
(url) =>
|
||||||
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
|
dockerSocketUrlRegex.test(url.trim()) ||
|
||||||
|
isDockerTlsUrl(url) ||
|
||||||
|
isCaptureDockerUrl(url),
|
||||||
|
"Docker host must be a socket path, a TLS daemon URL, or a Capture /metrics/docker endpoint"
|
||||||
),
|
),
|
||||||
|
secret: z.string(),
|
||||||
ignoreTlsErrors: z.boolean(),
|
ignoreTlsErrors: z.boolean(),
|
||||||
dockerTlsCa: z.string(),
|
dockerTlsCa: z.string(),
|
||||||
dockerTlsCert: z.string(),
|
dockerTlsCert: z.string(),
|
||||||
@@ -133,6 +152,8 @@ const dockerSchema = baseSchema.extend({
|
|||||||
// Read only mirror of the server flag, stripped on submit
|
// Read only mirror of the server flag, stripped on submit
|
||||||
dockerTlsKeySet: z.boolean(),
|
dockerTlsKeySet: z.boolean(),
|
||||||
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
|
dockerAlertOnStopped: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().register(monitorStepRegistry, { step: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Game server monitor schema
|
// Game server monitor schema
|
||||||
@@ -248,6 +269,14 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === "docker" && isCaptureDockerUrl(data.url) && !data.secret.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["secret"],
|
||||||
|
message: "Capture API secret is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
|
if (data.type === "docker" && isDockerTlsUrl(data.url)) {
|
||||||
if (!data.dockerTlsCert.trim()) {
|
if (!data.dockerTlsCert.trim()) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "صيانة",
|
"maintenance": "صيانة",
|
||||||
"paused": "متوقف مؤقتاً",
|
"paused": "متوقف مؤقتاً",
|
||||||
"up": "قيد التشغيل"
|
"up": "قيد التشغيل"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "فشل تحميل الحوادث."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"uptime": {
|
"uptime": {
|
||||||
"title": "Temps en línia",
|
"title": "Temps en línia",
|
||||||
"responseTime": "Temps de resposta"
|
"responseTime": "Temps de resposta"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "No s'han pogut carregar les incidències."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Údržba",
|
"maintenance": "Údržba",
|
||||||
"paused": "Pozastaveno",
|
"paused": "Pozastaveno",
|
||||||
"up": "V provozu"
|
"up": "V provozu"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Nepodařilo se načíst incidenty."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -448,6 +448,7 @@
|
|||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Aktiv seit",
|
"activeFor": "Aktiv seit",
|
||||||
|
"checkingEvery": "Check alle {{interval}}",
|
||||||
"certificateExpiry": "Zertifikatablauf",
|
"certificateExpiry": "Zertifikatablauf",
|
||||||
"lastCheck": "Letzter Check",
|
"lastCheck": "Letzter Check",
|
||||||
"lastResponseTime": "Letzte Antwortzeit",
|
"lastResponseTime": "Letzte Antwortzeit",
|
||||||
@@ -1377,6 +1378,9 @@
|
|||||||
"maintenance": "Wartung",
|
"maintenance": "Wartung",
|
||||||
"paused": "Pausiert",
|
"paused": "Pausiert",
|
||||||
"up": "Betriebsbereit"
|
"up": "Betriebsbereit"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Vorfälle konnten nicht geladen werden."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -226,7 +226,8 @@
|
|||||||
"alerting": "Alerting",
|
"alerting": "Alerting",
|
||||||
"reporting": "Reporting",
|
"reporting": "Reporting",
|
||||||
"configuration": "Configuration"
|
"configuration": "Configuration"
|
||||||
}
|
},
|
||||||
|
"dashboard": "Dashboard"
|
||||||
},
|
},
|
||||||
"bottomMenu": {
|
"bottomMenu": {
|
||||||
"support": "Support",
|
"support": "Support",
|
||||||
@@ -547,6 +548,7 @@
|
|||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Active for",
|
"activeFor": "Active for",
|
||||||
|
"checkingEvery": "Checking every {{interval}}",
|
||||||
"certificateExpiry": "Certificate expiry",
|
"certificateExpiry": "Certificate expiry",
|
||||||
"domainExpiry": "Domain expiry",
|
"domainExpiry": "Domain expiry",
|
||||||
"lastCheck": "Last check",
|
"lastCheck": "Last check",
|
||||||
@@ -555,7 +557,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"down": "down",
|
"down": "down",
|
||||||
"breached": "threshold exceeded",
|
"breached": "alert",
|
||||||
"initializing": "initializing",
|
"initializing": "initializing",
|
||||||
"maintenance": "maintenance",
|
"maintenance": "maintenance",
|
||||||
"paused": "paused",
|
"paused": "paused",
|
||||||
@@ -718,7 +720,7 @@
|
|||||||
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
|
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
|
||||||
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
|
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
|
||||||
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
|
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
|
||||||
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock, or a remote daemon as tcp://host:2376 to connect with TLS client certificates. Every container on the host is monitored.",
|
"docker": "Enter a Docker socket such as unix:///var/run/docker.sock, a remote daemon such as tcp://host:2376 with TLS client certificates, or a Capture endpoint ending in /metrics/docker with its authorization secret. Every container on the host is monitored.",
|
||||||
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
|
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
|
||||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||||
@@ -825,6 +827,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dockerAlerts": {
|
||||||
|
"title": "Container alerts",
|
||||||
|
"description": "Mark this host as breached while any container on it is stopped or failing its health check, and notify on the change. Containers that exit with code 0 do not count as stopped. Capture does not report exit codes, so on Capture hosts every stopped container counts.",
|
||||||
|
"option": {
|
||||||
|
"onStopped": {
|
||||||
|
"label": "Alert when a container is stopped"
|
||||||
|
},
|
||||||
|
"onUnhealthy": {
|
||||||
|
"label": "Alert when a container is unhealthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"title": "Monitor history",
|
"title": "Monitor history",
|
||||||
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
|
||||||
@@ -1683,7 +1697,7 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"up": "Operational",
|
"up": "Operational",
|
||||||
"down": "Down",
|
"down": "Down",
|
||||||
"breached": "Threshold exceeded",
|
"breached": "Alert",
|
||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"initializing": "Initializing"
|
"initializing": "Initializing"
|
||||||
@@ -1710,19 +1724,26 @@
|
|||||||
"uptime": {
|
"uptime": {
|
||||||
"title": "Uptime",
|
"title": "Uptime",
|
||||||
"responseTime": "Response time"
|
"responseTime": "Response time"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"modalTitle": "Incidents for {{date}}",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noIncidents": "No incidents reported for this day.",
|
||||||
|
"ongoing": "Ongoing",
|
||||||
|
"errorLoading": "Failed to load incidents."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
|
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
|
||||||
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
|
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
|
||||||
"allUp": "All systems operational",
|
"allUp": "All systems operational",
|
||||||
"allBreached": "All systems have exceeded thresholds",
|
"allBreached": "All systems have active alerts",
|
||||||
"allMaintenance": "All systems are under maintenance",
|
"allMaintenance": "All systems are under maintenance",
|
||||||
"allDown": "All systems are down",
|
"allDown": "All systems are down",
|
||||||
"allPaused": "All systems are paused",
|
"allPaused": "All systems are paused",
|
||||||
"allInitializing": "All systems are initializing",
|
"allInitializing": "All systems are initializing",
|
||||||
"breachedAndDown": "Some systems have exceeded thresholds and are down",
|
"breachedAndDown": "Some systems have active alerts and some are down",
|
||||||
"breached": "Some systems have exceeded thresholds",
|
"breached": "Some systems have active alerts",
|
||||||
"degraded": "Some systems are experiencing issues",
|
"degraded": "Some systems are experiencing issues",
|
||||||
"maintenanceAndDown": "Some systems are down during maintenance",
|
"maintenanceAndDown": "Some systems are down during maintenance",
|
||||||
"maintenance": "Some systems are under maintenance",
|
"maintenance": "Some systems are under maintenance",
|
||||||
@@ -1907,6 +1928,37 @@
|
|||||||
"title": "Uptime monitors",
|
"title": "Uptime monitors",
|
||||||
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
|
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"noData": "No data available at this moment.",
|
||||||
|
"monitorStatus": {
|
||||||
|
"ofUp": "of {{total}} up"
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"monitorStatus": "Monitor status",
|
||||||
|
"currentlyDown": "Currently down",
|
||||||
|
"uptime": "Monitor uptime",
|
||||||
|
"monitorsByType": "Monitors by type"
|
||||||
|
},
|
||||||
|
"uptimeCard": {
|
||||||
|
"toggleSortOrder": "Toggle sort order"
|
||||||
|
},
|
||||||
|
"currentlyDown": {
|
||||||
|
"empty": "No monitors are down at this moment."
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"description": "See every monitor type in one place and spot what needs attention."
|
||||||
|
},
|
||||||
|
"editCards": "Edit cards",
|
||||||
|
"editCardsModal": {
|
||||||
|
"title": "Dashboard cards",
|
||||||
|
"description": "Select the cards to display on your dashboard.",
|
||||||
|
"groups": {
|
||||||
|
"uptime": "Uptime"
|
||||||
|
},
|
||||||
|
"done": "Done"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Mantenimiento",
|
"maintenance": "Mantenimiento",
|
||||||
"paused": "Pausado",
|
"paused": "Pausado",
|
||||||
"up": "Operativo"
|
"up": "Operativo"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Error al cargar incidentes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Huolto",
|
"maintenance": "Huolto",
|
||||||
"paused": "Keskeytetty",
|
"paused": "Keskeytetty",
|
||||||
"up": "Toiminnassa"
|
"up": "Toiminnassa"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Häiriöiden lataaminen epäonnistui."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Maintenance",
|
"maintenance": "Maintenance",
|
||||||
"paused": "En pause",
|
"paused": "En pause",
|
||||||
"up": "Opérationnel"
|
"up": "Opérationnel"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Échec du chargement des incidents."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1473,6 +1473,9 @@
|
|||||||
"uptime": {
|
"uptime": {
|
||||||
"title": "Uptime",
|
"title": "Uptime",
|
||||||
"responseTime": "Tempo di risposta"
|
"responseTime": "Tempo di risposta"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Impossibile caricare gli incidenti."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "メンテナンス",
|
"maintenance": "メンテナンス",
|
||||||
"paused": "一時停止中",
|
"paused": "一時停止中",
|
||||||
"up": "稼働中"
|
"up": "稼働中"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "インシデントの読み込みに失敗しました。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1584,6 +1584,9 @@
|
|||||||
"uptime": {
|
"uptime": {
|
||||||
"responseTime": "Czas odpowiedzi",
|
"responseTime": "Czas odpowiedzi",
|
||||||
"title": "Czas pracy"
|
"title": "Czas pracy"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Nie udało się załadować incydentów."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Manutenção",
|
"maintenance": "Manutenção",
|
||||||
"paused": "Pausado",
|
"paused": "Pausado",
|
||||||
"up": "Operacional"
|
"up": "Operacional"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Falha ao carregar incidentes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Обслуживание",
|
"maintenance": "Обслуживание",
|
||||||
"paused": "Приостановлено",
|
"paused": "Приостановлено",
|
||||||
"up": "Работает"
|
"up": "Работает"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Не удалось загрузить инциденты."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "การบำรุงรักษา",
|
"maintenance": "การบำรุงรักษา",
|
||||||
"paused": "หยุดชั่วคราว",
|
"paused": "หยุดชั่วคราว",
|
||||||
"up": "ใช้งานได้"
|
"up": "ใช้งานได้"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "ไม่สามารถโหลดเหตุการณ์ได้"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Bakım",
|
"maintenance": "Bakım",
|
||||||
"paused": "Duraklatıldı",
|
"paused": "Duraklatıldı",
|
||||||
"up": "Çalışıyor"
|
"up": "Çalışıyor"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Olaylar yüklenemedi."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Обслуговування",
|
"maintenance": "Обслуговування",
|
||||||
"paused": "Призупинено",
|
"paused": "Призупинено",
|
||||||
"up": "Працює"
|
"up": "Працює"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Не вдалося завантажити інциденти."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "Bảo trì",
|
"maintenance": "Bảo trì",
|
||||||
"paused": "Tạm dừng",
|
"paused": "Tạm dừng",
|
||||||
"up": "Hoạt động"
|
"up": "Hoạt động"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "Không thể tải sự cố."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1378,6 +1378,9 @@
|
|||||||
"maintenance": "维护中",
|
"maintenance": "维护中",
|
||||||
"paused": "已暂停",
|
"paused": "已暂停",
|
||||||
"up": "运行中"
|
"up": "运行中"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "无法加载事件。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"maintenance": "維護中",
|
"maintenance": "維護中",
|
||||||
"paused": "已暫停",
|
"paused": "已暫停",
|
||||||
"up": "運行中"
|
"up": "運行中"
|
||||||
|
},
|
||||||
|
"incidents": {
|
||||||
|
"errorLoading": "無法載入事件。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusBar": {
|
"statusBar": {
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
|
||||||
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
# pure JS so it's arch-independent, and this avoids the esbuild host/native
|
||||||
# binary version mismatch under emulation.
|
# binary version mismatch under emulation.
|
||||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
|
FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build
|
||||||
|
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ RUN npm run build
|
|||||||
# ---------------------
|
# ---------------------
|
||||||
# Backend stage
|
# Backend stage
|
||||||
# ---------------------
|
# ---------------------
|
||||||
FROM node:22-slim AS app
|
FROM node:24-slim AS app
|
||||||
|
|
||||||
# Install ping
|
# Install ping
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
|||||||
@@ -466,6 +466,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -523,6 +529,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -694,6 +702,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -753,6 +767,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -7186,6 +7202,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -7245,6 +7267,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -7761,6 +7785,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -7820,6 +7850,8 @@
|
|||||||
"geoCheckLocations",
|
"geoCheckLocations",
|
||||||
"geoCheckInterval",
|
"geoCheckInterval",
|
||||||
"dockerLogsEnabled",
|
"dockerLogsEnabled",
|
||||||
|
"dockerAlertOnStopped",
|
||||||
|
"dockerAlertOnUnhealthy",
|
||||||
"teamId",
|
"teamId",
|
||||||
"userId",
|
"userId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
@@ -9308,6 +9340,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -10033,6 +10071,14 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"dnsServer": {
|
"dnsServer": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
@@ -10600,6 +10646,12 @@
|
|||||||
"dockerLogsEnabled": {
|
"dockerLogsEnabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"dockerAlertOnStopped": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dockerAlertOnUnhealthy": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"dockerTlsCa": {
|
"dockerTlsCa": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -13294,6 +13346,25 @@
|
|||||||
"globalProxyId": {
|
"globalProxyId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"egressCheckEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"egressCheckTargets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 253
|
||||||
|
},
|
||||||
|
"maxItems": 10
|
||||||
|
},
|
||||||
|
"egressNotifications": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-fA-F]{24}$"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+492
-444
File diff suppressed because it is too large.
Load diff
+8
-6
@@ -15,7 +15,7 @@
|
|||||||
"worker": "QUEUE_MODE=worker node ./dist/index.js",
|
"worker": "QUEUE_MODE=worker node ./dist/index.js",
|
||||||
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
|
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
|
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates && mkdir -p dist/service && cp -r src/service/protos dist/service/",
|
||||||
"typeCheck": "tsc --noEmit",
|
"typeCheck": "tsc --noEmit",
|
||||||
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
|
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
@@ -42,9 +43,10 @@
|
|||||||
"compression": "1.8.1",
|
"compression": "1.8.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
"dockerode": "5.0.0",
|
"dockerode": "5.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "^8.5.1",
|
"express-rate-limit": "^8.5.1",
|
||||||
"gamedig": "5.2.0",
|
"gamedig": "5.2.0",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
@@ -57,11 +59,11 @@
|
|||||||
"jsonwebtoken": "9.0.2",
|
"jsonwebtoken": "9.0.2",
|
||||||
"mjml": "^5.0.0-alpha.4",
|
"mjml": "^5.0.0-alpha.4",
|
||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "2.4.0",
|
||||||
"nodemailer": "9.0.5",
|
"nodemailer": "9.1.1",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"re2": "^1.24.1",
|
"re2": "^1.24.1",
|
||||||
"sharp": "0.35.3",
|
"sharp": "0.35.4",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tldts": "^7.4.10",
|
"tldts": "^7.4.10",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import { UserRole } from "@/domain/users/user.type.js";
|
import { UserRole } from "@/domain/users/user.type.js";
|
||||||
import sslChecker, { SSLDetails } from "ssl-checker";
|
import sslChecker, { SSLDetails } from "ssl-checker";
|
||||||
import * as whoiser from "whoiser";
|
import * as whoiser from "whoiser";
|
||||||
@@ -124,23 +124,6 @@ export const fetchMonitorDomain = async (
|
|||||||
return domainExpiry;
|
return domainExpiry;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requireString = (value: unknown, fieldName: string): string => {
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new AppError({ message: `${fieldName} is required`, status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractString = (value: unknown): string | undefined => {
|
export const extractString = (value: unknown): string | undefined => {
|
||||||
const candidate = Array.isArray(value) ? value[0] : value;
|
const candidate = Array.isArray(value) ? value[0] : value;
|
||||||
if (typeof candidate !== "string" || candidate.length === 0) {
|
if (typeof candidate !== "string" || candidate.length === 0) {
|
||||||
@@ -149,52 +132,6 @@ export const extractString = (value: unknown): string | undefined => {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isNaN(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
|
|
||||||
const parseSingle = (input: unknown): MonitorType => {
|
|
||||||
if (typeof input !== "string") {
|
|
||||||
throw new AppError({ message: "Monitor type must be a string", status: 400 });
|
|
||||||
}
|
|
||||||
if (!MonitorTypes.includes(input as MonitorType)) {
|
|
||||||
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
|
|
||||||
}
|
|
||||||
return input as MonitorType;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((entry) => parseSingle(entry));
|
|
||||||
}
|
|
||||||
return parseSingle(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (value === "asc" || value === "desc") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requireTeamId = (teamId?: string): string => {
|
export const requireTeamId = (teamId?: string): string => {
|
||||||
if (!teamId) {
|
if (!teamId) {
|
||||||
throw new AppError({ message: "Team ID is required", status: 400 });
|
throw new AppError({ message: "Team ID is required", status: 400 });
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ class NotificationController implements INotificationController {
|
|||||||
|
|
||||||
testNotification = catchAsync(async (req: Request, res: Response) => {
|
testNotification = catchAsync(async (req: Request, res: Response) => {
|
||||||
const notification = testNotificationBodyValidation.parse(req.body);
|
const notification = testNotificationBodyValidation.parse(req.body);
|
||||||
const success = await this.notificationsService.sendTestNotification(notification);
|
let success = false;
|
||||||
|
let reason: string | undefined;
|
||||||
|
try {
|
||||||
|
success = await this.notificationsService.sendTestNotification(notification);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof AppError)) throw error;
|
||||||
|
reason = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success,
|
success,
|
||||||
msg: success ? "Notification sent successfully" : "Notification could not be sent — check the destination details.",
|
msg: success ? "Notification sent successfully" : (reason ?? "Notification could not be sent — check the destination details."),
|
||||||
details: { service: SERVICE_NAME },
|
details: { service: SERVICE_NAME },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { AppError } from "@/utils/AppError.js";
|
|||||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||||
import { IEmailService } from "@/service/emailService.js";
|
import { IEmailService } from "@/service/emailService.js";
|
||||||
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
|
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
|
||||||
|
import { IEgressStateService } from "@/domain/egress/egress-state.service.js";
|
||||||
import { Settings } from "@/domain/app-settings/app-settings.type.js";
|
import { Settings } from "@/domain/app-settings/app-settings.type.js";
|
||||||
|
|
||||||
|
const SERVICE_NAME = "SettingsController";
|
||||||
|
|
||||||
export interface ISettingsController {
|
export interface ISettingsController {
|
||||||
getAppSettings: RequestHandler;
|
getAppSettings: RequestHandler;
|
||||||
updateAppSettings: RequestHandler;
|
updateAppSettings: RequestHandler;
|
||||||
@@ -18,10 +21,17 @@ class SettingsController implements ISettingsController {
|
|||||||
private settingsService: ISettingsService;
|
private settingsService: ISettingsService;
|
||||||
private emailService: IEmailService;
|
private emailService: IEmailService;
|
||||||
private proxiesService: IProxiesService;
|
private proxiesService: IProxiesService;
|
||||||
constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) {
|
private egressStateService: IEgressStateService;
|
||||||
|
constructor(
|
||||||
|
settingsService: ISettingsService,
|
||||||
|
emailService: IEmailService,
|
||||||
|
proxiesService: IProxiesService,
|
||||||
|
egressStateService: IEgressStateService
|
||||||
|
) {
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.emailService = emailService;
|
this.emailService = emailService;
|
||||||
this.proxiesService = proxiesService;
|
this.proxiesService = proxiesService;
|
||||||
|
this.egressStateService = egressStateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildAppSettings = async (dbSettings: Settings) => {
|
buildAppSettings = async (dbSettings: Settings) => {
|
||||||
@@ -70,7 +80,14 @@ class SettingsController implements ISettingsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousSettings = await this.settingsService.getDBSettings();
|
||||||
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
|
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
|
||||||
|
|
||||||
|
// Switching the egress check on or off starts from a clean state: no degraded episode, no pending recovery job
|
||||||
|
if (validatedBody.egressCheckEnabled !== undefined && validatedBody.egressCheckEnabled !== previousSettings.egressCheckEnabled) {
|
||||||
|
await this.egressStateService.reset();
|
||||||
|
}
|
||||||
|
|
||||||
const returnSettings = await this.buildAppSettings(updatedSettings);
|
const returnSettings = await this.buildAppSettings(updatedSettings);
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -104,7 +121,7 @@ class SettingsController implements ISettingsController {
|
|||||||
|
|
||||||
const html = await this.emailService.buildEmail("testEmailTemplate", context);
|
const html = await this.emailService.buildEmail("testEmailTemplate", context);
|
||||||
if (!html) {
|
if (!html) {
|
||||||
throw new AppError({ message: "Failed to build email template.", status: 500 });
|
throw new AppError({ message: "Failed to build email template.", status: 500, service: SERVICE_NAME, method: "sendTestEmail" });
|
||||||
}
|
}
|
||||||
let messageId: string;
|
let messageId: string;
|
||||||
try {
|
try {
|
||||||
@@ -127,8 +144,11 @@ class SettingsController implements ISettingsController {
|
|||||||
// Surface the underlying SMTP failure: diagnosing the settings is the whole
|
// Surface the underlying SMTP failure: diagnosing the settings is the whole
|
||||||
// point of the test email endpoint.
|
// point of the test email endpoint.
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
message: error instanceof Error ? `Failed to send test email. ${error.message}` : "Failed to send test email.",
|
message: error instanceof AppError ? error.message : "Failed to send test email.",
|
||||||
status: 500,
|
status: error instanceof AppError ? error.status : 500,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "sendTestEmail",
|
||||||
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
getStatusPageQueryValidation,
|
getStatusPageQueryValidation,
|
||||||
imageValidation,
|
imageValidation,
|
||||||
resolveStatusPageQueryValidation,
|
resolveStatusPageQueryValidation,
|
||||||
|
getPublicMonitorIncidentsParamValidation,
|
||||||
|
getPublicMonitorIncidentsQueryValidation,
|
||||||
} from "@/api/validation/statusPageValidation.js";
|
} from "@/api/validation/statusPageValidation.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
|
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
|
||||||
@@ -17,6 +19,7 @@ export interface IStatusPageController {
|
|||||||
createStatusPage: RequestHandler;
|
createStatusPage: RequestHandler;
|
||||||
updateStatusPage: RequestHandler;
|
updateStatusPage: RequestHandler;
|
||||||
getStatusPageByUrl: RequestHandler;
|
getStatusPageByUrl: RequestHandler;
|
||||||
|
getPublicMonitorIncidents: RequestHandler;
|
||||||
resolveStatusPageByDomain: RequestHandler;
|
resolveStatusPageByDomain: RequestHandler;
|
||||||
getStatusPagesByTeamId: RequestHandler;
|
getStatusPagesByTeamId: RequestHandler;
|
||||||
deleteStatusPage: RequestHandler;
|
deleteStatusPage: RequestHandler;
|
||||||
@@ -85,6 +88,19 @@ class StatusPageController implements IStatusPageController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getPublicMonitorIncidents = catchAsync(async (req: Request, res: Response) => {
|
||||||
|
const { url, monitorId } = getPublicMonitorIncidentsParamValidation.parse(req.params);
|
||||||
|
const { date } = getPublicMonitorIncidentsQueryValidation.parse(req.query);
|
||||||
|
|
||||||
|
const incidents = await this.statusPageService.getPublicMonitorIncidents(url, monitorId, date, req.user?.teamId);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
msg: "Incidents retrieved successfully",
|
||||||
|
data: { incidents },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => {
|
resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => {
|
||||||
const { range } = resolveStatusPageQueryValidation.parse(req.query);
|
const { range } = resolveStatusPageQueryValidation.parse(req.query);
|
||||||
const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined);
|
const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined);
|
||||||
|
|||||||
@@ -53,14 +53,10 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
|
|||||||
|
|
||||||
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (req.query && typeof req.query === "object") {
|
const sanitized = Object.fromEntries(
|
||||||
for (const key of Object.keys(req.query)) {
|
Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
|
||||||
const value = req.query[key];
|
);
|
||||||
if (typeof value === "string") {
|
Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
|
||||||
req.query[key] = sanitizeInput(value, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -16,7 +16,15 @@ export const isPublicStatusPageApiPath = (method: string, path: string): boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!slug || slug === "team" || slug.includes("/")) {
|
if (!slug || slug === "team") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.includes("/")) {
|
||||||
|
const parts = slug.split("/");
|
||||||
|
if (parts.length === 3 && parts[1] === "incidents") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const createStatusPageRoutes = (
|
|||||||
router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.updateStatusPage);
|
router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.updateStatusPage);
|
||||||
router.get("/resolve", statusPageController.resolveStatusPageByDomain);
|
router.get("/resolve", statusPageController.resolveStatusPageByDomain);
|
||||||
router.get("/:url", verifyStatusPageAccess, statusPageController.getStatusPageByUrl);
|
router.get("/:url", verifyStatusPageAccess, statusPageController.getStatusPageByUrl);
|
||||||
|
router.get("/:url/incidents/:monitorId", verifyStatusPageAccess, statusPageController.getPublicMonitorIncidents);
|
||||||
router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.deleteStatusPage);
|
router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.deleteStatusPage);
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { DateRanges, SortOrders } from "@/types/query.js";
|
import { DateRanges, SortOrders } from "@/types/query.js";
|
||||||
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.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 { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
|
||||||
import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
import { isCaptureDockerUrl, isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||||
import { X509Certificate } from "node:crypto";
|
import { X509Certificate } from "node:crypto";
|
||||||
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
|
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
|
||||||
|
|
||||||
@@ -125,15 +125,21 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
|
|||||||
|
|
||||||
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
|
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
|
||||||
if (data.type !== "docker" || data.url === undefined) return;
|
if (data.type !== "docker" || data.url === undefined) return;
|
||||||
if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url)) {
|
if (!isDockerSocketUrl(data.url) && !isDockerTlsUrl(data.url) && !isCaptureDockerUrl(data.url)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
path: ["url"],
|
path: ["url"],
|
||||||
message: "Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]",
|
message: "Docker host must be a socket path, a TLS daemon URL, or a Capture /metrics/docker endpoint",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Edits are checked in the monitor service against the stored secret, since the body may omit either the url or the secret.
|
||||||
|
const refineCaptureSecret = (data: { type?: string; url?: string; secret?: string }, ctx: z.RefinementCtx) => {
|
||||||
|
if (data.type !== "docker" || !isCaptureDockerUrl(data.url)) return;
|
||||||
|
if (!data.secret?.trim()) ctx.addIssue({ code: "custom", path: ["secret"], message: "Capture API secret is required" });
|
||||||
|
};
|
||||||
|
|
||||||
type DockerTlsFields = {
|
type DockerTlsFields = {
|
||||||
type?: string;
|
type?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -214,6 +220,8 @@ export const createMonitorBodyValidation = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||||
geoCheckInterval: z.number().min(300000).optional(),
|
geoCheckInterval: z.number().min(300000).optional(),
|
||||||
dockerLogsEnabled: z.boolean().optional(),
|
dockerLogsEnabled: z.boolean().optional(),
|
||||||
|
dockerAlertOnStopped: z.boolean().optional(),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().optional(),
|
||||||
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
||||||
@@ -226,6 +234,7 @@ export const createMonitorBodyValidation = z
|
|||||||
.superRefine(refineRegexPattern)
|
.superRefine(refineRegexPattern)
|
||||||
.superRefine(refineProxySelection)
|
.superRefine(refineProxySelection)
|
||||||
.superRefine(refineDockerUrl)
|
.superRefine(refineDockerUrl)
|
||||||
|
.superRefine(refineCaptureSecret)
|
||||||
.superRefine(refineDockerTls("create"));
|
.superRefine(refineDockerTls("create"));
|
||||||
|
|
||||||
export const editMonitorBodyValidation = z
|
export const editMonitorBodyValidation = z
|
||||||
@@ -263,6 +272,8 @@ export const editMonitorBodyValidation = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
|
||||||
geoCheckInterval: z.number().min(300000).optional(),
|
geoCheckInterval: z.number().min(300000).optional(),
|
||||||
dockerLogsEnabled: z.boolean().optional(),
|
dockerLogsEnabled: z.boolean().optional(),
|
||||||
|
dockerAlertOnStopped: z.boolean().optional(),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().optional(),
|
||||||
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
|
||||||
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
|
||||||
@@ -343,6 +354,8 @@ const importedMonitorSchema = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
|
||||||
geoCheckInterval: z.number().min(300000).default(300000),
|
geoCheckInterval: z.number().min(300000).default(300000),
|
||||||
dockerLogsEnabled: z.boolean().default(false),
|
dockerLogsEnabled: z.boolean().default(false),
|
||||||
|
dockerAlertOnStopped: z.boolean().default(false),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean().default(false),
|
||||||
dnsServer: dnsServerValidation.optional(),
|
dnsServer: dnsServerValidation.optional(),
|
||||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||||
createdAt: z.string().optional(),
|
createdAt: z.string().optional(),
|
||||||
@@ -353,7 +366,8 @@ const importedMonitorSchema = z
|
|||||||
.superRefine(refineHeadMatching)
|
.superRefine(refineHeadMatching)
|
||||||
.superRefine(refineRegexPattern)
|
.superRefine(refineRegexPattern)
|
||||||
.superRefine(refineProxySelection)
|
.superRefine(refineProxySelection)
|
||||||
.superRefine(refineDockerUrl);
|
.superRefine(refineDockerUrl)
|
||||||
|
.superRefine(refineCaptureSecret);
|
||||||
|
|
||||||
export const importMonitorsBodyValidation = z.object({
|
export const importMonitorsBodyValidation = z.object({
|
||||||
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
|
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
|
||||||
@@ -432,6 +446,8 @@ export const monitorResponseSchema = z
|
|||||||
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
geoCheckLocations: z.array(z.enum(GeoContinents)),
|
||||||
geoCheckInterval: z.number(),
|
geoCheckInterval: z.number(),
|
||||||
dockerLogsEnabled: z.boolean(),
|
dockerLogsEnabled: z.boolean(),
|
||||||
|
dockerAlertOnStopped: z.boolean(),
|
||||||
|
dockerAlertOnUnhealthy: z.boolean(),
|
||||||
dockerTlsCa: z.string().optional(),
|
dockerTlsCa: z.string().optional(),
|
||||||
dockerTlsCert: z.string().optional(),
|
dockerTlsCert: z.string().optional(),
|
||||||
dockerTlsKeySet: z.boolean().optional(),
|
dockerTlsKeySet: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
|
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
|
||||||
|
import { MAX_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
|
||||||
|
import { parseEgressTarget } from "@/utils/egressTarget.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// A reliability target is a bare host/IP (ICMP ping), host:port (TCP connect) or an http(s) URL.
|
||||||
|
// Parsed with the same function the probe uses, so nothing the validator accepts can fail to probe.
|
||||||
|
const egressTargetValidation = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.refine((target) => parseEgressTarget(target) !== null, "Enter a hostname or IP address, host:port, or http(s) URL");
|
||||||
|
|
||||||
//****************************************
|
//****************************************
|
||||||
// Settings Validations
|
// Settings Validations
|
||||||
//****************************************
|
//****************************************
|
||||||
@@ -42,6 +53,10 @@ export const updateAppSettingsBodyValidation = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
globalProxyEnabled: z.boolean().optional(),
|
globalProxyEnabled: z.boolean().optional(),
|
||||||
globalProxyId: z.string().nullable().optional(),
|
globalProxyId: z.string().nullable().optional(),
|
||||||
|
|
||||||
|
egressCheckEnabled: z.boolean().optional(),
|
||||||
|
egressCheckTargets: z.array(egressTargetValidation).max(MAX_EGRESS_TARGETS).optional(), // empty list falls back to DEFAULT_EGRESS_TARGETS
|
||||||
|
egressNotifications: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid notification id")).optional(),
|
||||||
})
|
})
|
||||||
.strip()
|
.strip()
|
||||||
.superRefine((body, ctx) => {
|
.superRefine((body, ctx) => {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export const getStatusPageParamValidation = z.object({
|
|||||||
url: z.string().min(1, "URL is required"),
|
url: z.string().min(1, "URL is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getPublicMonitorIncidentsParamValidation = z.object({
|
||||||
|
url: z.string().min(1, "URL is required"),
|
||||||
|
monitorId: z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPublicMonitorIncidentsQueryValidation = z.object({
|
||||||
|
date: z.string().date("Date must be a valid calendar date in YYYY-MM-DD format"),
|
||||||
|
});
|
||||||
|
|
||||||
export const getStatusPageQueryValidation = z.object({
|
export const getStatusPageQueryValidation = z.object({
|
||||||
type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
|
type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
|
||||||
range: z.enum(StatusPageRanges).optional().default("latest"),
|
range: z.enum(StatusPageRanges).optional().default("latest"),
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ export const createApp = ({
|
|||||||
setupRoutes(app, controllers, services);
|
setupRoutes(app, controllers, services);
|
||||||
|
|
||||||
// FE routes
|
// FE routes
|
||||||
app.get("*", (req, res) => {
|
app.get("/{*splat}", (req, res) => {
|
||||||
res.sendFile(path.join(frontendPath, "index.html"));
|
res.sendFile(path.join(frontendPath, "index.html"));
|
||||||
});
|
});
|
||||||
app.use(handleErrors(services.logger));
|
app.use(handleErrors(services.logger));
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
|
|||||||
return {
|
return {
|
||||||
authController: new AuthController(apiServices.userService),
|
authController: new AuthController(apiServices.userService),
|
||||||
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
|
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
|
||||||
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService),
|
settingsController: new SettingsController(
|
||||||
|
apiServices.settingsService,
|
||||||
|
apiServices.emailService,
|
||||||
|
apiServices.proxiesService,
|
||||||
|
apiServices.egressStateService
|
||||||
|
),
|
||||||
checkController: new CheckController(apiServices.checkService),
|
checkController: new CheckController(apiServices.checkService),
|
||||||
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
|
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
|
||||||
inviteController: new InviteController(apiServices.inviteService),
|
inviteController: new InviteController(apiServices.inviteService),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
|
|||||||
import { IUserService, UserService } from "@/domain/users/user.service.js";
|
import { IUserService, UserService } from "@/domain/users/user.service.js";
|
||||||
import { IJobScheduler } from "@/worker/worker.interface.js";
|
import { IJobScheduler } from "@/worker/worker.interface.js";
|
||||||
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
|
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
|
||||||
|
import { EgressStateService, IEgressStateService } from "@/domain/egress/egress-state.service.js";
|
||||||
import { SharedServices } from "@/config/services.shared.js";
|
import { SharedServices } from "@/config/services.shared.js";
|
||||||
|
|
||||||
// Third-party
|
// Third-party
|
||||||
@@ -24,6 +25,7 @@ export interface ApiServices extends SharedServices {
|
|||||||
tagsService: ITagsService;
|
tagsService: ITagsService;
|
||||||
diagnosticService: IDiagnosticService;
|
diagnosticService: IDiagnosticService;
|
||||||
proxiesService: IProxiesService;
|
proxiesService: IProxiesService;
|
||||||
|
egressStateService: IEgressStateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
|
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
|
||||||
@@ -48,6 +50,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
|||||||
maintenanceWindowsRepository,
|
maintenanceWindowsRepository,
|
||||||
jobsRepository,
|
jobsRepository,
|
||||||
proxiesRepository,
|
proxiesRepository,
|
||||||
|
egressStateRepository,
|
||||||
} = shared;
|
} = shared;
|
||||||
|
|
||||||
const userService = new UserService({
|
const userService = new UserService({
|
||||||
@@ -96,10 +99,11 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
|||||||
emailService,
|
emailService,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository);
|
const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository, incidentsRepository);
|
||||||
const tagsService = new TagsService(tagsRepository, monitorsRepository);
|
const tagsService = new TagsService(tagsRepository, monitorsRepository);
|
||||||
const diagnosticService = new DiagnosticService(db);
|
const diagnosticService = new DiagnosticService(db);
|
||||||
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
|
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
|
||||||
|
const egressStateService = new EgressStateService(egressStateRepository, jobsRepository);
|
||||||
return {
|
return {
|
||||||
...shared,
|
...shared,
|
||||||
worker: jobScheduler,
|
worker: jobScheduler,
|
||||||
@@ -111,5 +115,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
|
|||||||
tagsService,
|
tagsService,
|
||||||
diagnosticService,
|
diagnosticService,
|
||||||
proxiesService,
|
proxiesService,
|
||||||
|
egressStateService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -56,6 +56,7 @@ import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
|
|||||||
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
|
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
|
||||||
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
|
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
|
||||||
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
|
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
|
||||||
|
import { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
|
||||||
|
|
||||||
// Mongo repository implementations
|
// Mongo repository implementations
|
||||||
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
|
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
|
||||||
@@ -76,6 +77,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
|
|||||||
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
|
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
|
||||||
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
|
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
|
||||||
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
|
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
|
||||||
|
import MongoEgressStateRepository from "@/domain/egress/egress-state.repository.mongo.js";
|
||||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||||
|
|
||||||
// Shared infrastructure + business services that both the API and the worker process construct.
|
// Shared infrastructure + business services that both the API and the worker process construct.
|
||||||
@@ -114,6 +116,7 @@ export interface SharedServices {
|
|||||||
teamsRepository: ITeamsRepository;
|
teamsRepository: ITeamsRepository;
|
||||||
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
|
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
|
||||||
proxiesRepository: IProxiesRepository;
|
proxiesRepository: IProxiesRepository;
|
||||||
|
egressStateRepository: IEgressStateRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildShared = async ({
|
export const buildShared = async ({
|
||||||
@@ -157,6 +160,7 @@ export const buildShared = async ({
|
|||||||
const teamsRepository = new MongoTeamsRepository();
|
const teamsRepository = new MongoTeamsRepository();
|
||||||
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
||||||
const proxiesRepository = new MongoProxiesRepository();
|
const proxiesRepository = new MongoProxiesRepository();
|
||||||
|
const egressStateRepository = new MongoEgressStateRepository();
|
||||||
|
|
||||||
// Inject settings repository into settings service (now that DB is connected)
|
// Inject settings repository into settings service (now that DB is connected)
|
||||||
(settingsService as SettingsService).setRepository(settingsRepository);
|
(settingsService as SettingsService).setRepository(settingsRepository);
|
||||||
@@ -246,6 +250,7 @@ export const buildShared = async ({
|
|||||||
teamsRepository,
|
teamsRepository,
|
||||||
maintenanceWindowsRepository,
|
maintenanceWindowsRepository,
|
||||||
proxiesRepository,
|
proxiesRepository,
|
||||||
|
egressStateRepository,
|
||||||
};
|
};
|
||||||
return sharedServices;
|
return sharedServices;
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Resolver } from "dns/promises";
|
import { Resolver } from "dns/promises";
|
||||||
import axios from "axios";
|
|
||||||
import got from "got";
|
import got from "got";
|
||||||
import ping from "ping";
|
import ping from "ping";
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
@@ -13,39 +12,39 @@ import mongoose from "mongoose";
|
|||||||
|
|
||||||
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
||||||
import { SharedServices } from "@/config/services.shared.js";
|
import { SharedServices } from "@/config/services.shared.js";
|
||||||
import { INetworkService, NetworkService } from "@/service/networkService.js";
|
import { IProviderRegistry, ProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
|
||||||
import { IBufferService, BufferService } from "@/service/bufferService.js";
|
import { IBufferService, BufferService } from "@/service/bufferService.js";
|
||||||
import { IStatusService, StatusService } from "@/service/statusService.js";
|
import { IStatusService, StatusService } from "@/service/statusService.js";
|
||||||
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
|
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
|
||||||
import { IQueueWorker } from "@/worker/worker.interface.js";
|
import { IQueueWorker, JobHandlers } from "@/worker/worker.interface.js";
|
||||||
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
|
|
||||||
import { WorkerHelper } from "@/worker/worker.helper.js";
|
import { WorkerHelper } from "@/worker/worker.helper.js";
|
||||||
import { CheckProducer } from "@/worker/worker.check-producer.js";
|
import { GeoChecksPipeline } from "@/worker/worker.geo-pipeline.js";
|
||||||
import { CheckEvaluator } from "@/worker/worker.check-evaluator.js";
|
|
||||||
import { GeoChecksPipeline } from "@/worker/worker.check-pipeline.js";
|
|
||||||
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
||||||
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
||||||
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||||
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
||||||
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
|
import { ProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
|
||||||
|
import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
|
||||||
// Network providers
|
// Network providers
|
||||||
import { PingProvider } from "@/service/network/PingProvider.js";
|
import { PingProvider } from "@/service/networkProviders/PingProvider.js";
|
||||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
|
||||||
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
|
import { AdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
|
||||||
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
|
import { PageSpeedProvider } from "@/service/networkProviders/PageSpeedProvider.js";
|
||||||
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
|
import { HardwareProvider } from "@/service/networkProviders/HardwareProvider.js";
|
||||||
import { DockerProvider } from "@/service/network/DockerProvider.js";
|
import { DockerProvider } from "@/service/networkProviders/DockerProvider.js";
|
||||||
import { PortProvider } from "@/service/network/PortProvider.js";
|
import { PortProvider } from "@/service/networkProviders/PortProvider.js";
|
||||||
import { GameProvider } from "@/service/network/GameProvider.js";
|
import { GameProvider } from "@/service/networkProviders/GameProvider.js";
|
||||||
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
|
import { GrpcProvider } from "@/service/networkProviders/GrpcProvider.js";
|
||||||
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
|
import { WebSocketProvider } from "@/service/networkProviders/WebSocketProvider.js";
|
||||||
import { DNSProvider } from "@/service/network/DNSProvider.js";
|
import { DNSProvider } from "@/service/networkProviders/DNSProvider.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
|
||||||
export interface WorkerServices {
|
export interface WorkerServices {
|
||||||
worker: IQueueWorker;
|
worker: IQueueWorker;
|
||||||
networkService: INetworkService;
|
networkService: IProviderRegistry;
|
||||||
bufferService: IBufferService;
|
bufferService: IBufferService;
|
||||||
statusService: IStatusService;
|
statusService: IStatusService;
|
||||||
|
egressService: IEgressService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig): Promise<WorkerServices> => {
|
export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig): Promise<WorkerServices> => {
|
||||||
@@ -70,23 +69,24 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
teamsRepository,
|
teamsRepository,
|
||||||
maintenanceWindowsRepository,
|
maintenanceWindowsRepository,
|
||||||
proxiesRepository,
|
proxiesRepository,
|
||||||
|
egressStateRepository,
|
||||||
} = shared;
|
} = shared;
|
||||||
|
|
||||||
// ***********************
|
// ***********************
|
||||||
// Network providers
|
// Network providers
|
||||||
// ***********************
|
// ***********************
|
||||||
const pingProvider = new PingProvider(ping);
|
const pingProvider = new PingProvider(ping, net);
|
||||||
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
|
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
|
||||||
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
|
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
|
||||||
const hardwareProvider = new HardwareProvider(httpProvider);
|
const hardwareProvider = new HardwareProvider(httpProvider);
|
||||||
const dockerProvider = new DockerProvider(logger, Docker, encryptionService);
|
const dockerProvider = new DockerProvider(logger, Docker, encryptionService, httpProvider);
|
||||||
const portProvider = new PortProvider(net);
|
const portProvider = new PortProvider(net);
|
||||||
const gameProvider = new GameProvider(logger, GameDig);
|
const gameProvider = new GameProvider(logger, GameDig);
|
||||||
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
const grpcProvider = new GrpcProvider(grpc, protoLoader);
|
||||||
const webSocketProvider = new WebSocketProvider(WebSocket);
|
const webSocketProvider = new WebSocketProvider(WebSocket);
|
||||||
const dnsProvider = new DNSProvider(() => new Resolver());
|
const dnsProvider = new DNSProvider(() => new Resolver());
|
||||||
|
|
||||||
const networkService = new NetworkService(axios, logger, [
|
const providerRegistry = new ProviderRegistry([
|
||||||
pingProvider,
|
pingProvider,
|
||||||
httpProvider,
|
httpProvider,
|
||||||
pageSpeedProvider,
|
pageSpeedProvider,
|
||||||
@@ -100,35 +100,33 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
|
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
|
||||||
const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
|
const bufferService = new BufferService(logger, geoChecksService, dockerLogsService, settingsService, (checks): Promise<void> =>
|
||||||
|
pipeline.ingestChecks(checks)
|
||||||
|
);
|
||||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||||
const monitorStatusPolicy = new MonitorStatusPolicy();
|
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, providerRegistry, proxyResolver, logger);
|
||||||
|
|
||||||
// ***********************
|
|
||||||
// Reactors and dispatcher
|
|
||||||
// Handles notifications and incidents
|
|
||||||
// ***********************
|
|
||||||
|
|
||||||
const notificationReactor = new NotificationReactor(notificationsService);
|
const notificationReactor = new NotificationReactor(notificationsService);
|
||||||
const incidentReactor = new IncidentReactor(incidentService);
|
const incidentReactor = new IncidentReactor(incidentService);
|
||||||
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
||||||
|
|
||||||
// ***********************
|
const pipeline = new WorkerPipeline({
|
||||||
// Check producer/evaluator
|
logger,
|
||||||
// Handles creating and evaluatiog checks
|
|
||||||
// ***********************
|
|
||||||
const checkProducer = new CheckProducer(
|
|
||||||
monitorsRepository,
|
monitorsRepository,
|
||||||
maintenanceWindowsRepository,
|
maintenanceWindowsRepository,
|
||||||
|
checksRepository,
|
||||||
|
jobsRepository,
|
||||||
checkService,
|
checkService,
|
||||||
networkService,
|
providerRegistry: providerRegistry,
|
||||||
proxyResolver,
|
proxyResolver,
|
||||||
bufferService,
|
bufferService,
|
||||||
dockerLogsService,
|
dockerLogsService,
|
||||||
logger
|
egressService,
|
||||||
);
|
statusService,
|
||||||
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
|
dispatcher: reactorDispatcher,
|
||||||
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
|
});
|
||||||
|
|
||||||
|
const geoCheckPipeline = new GeoChecksPipeline(monitorsRepository, maintenanceWindowsRepository, geoChecksService, bufferService, logger);
|
||||||
|
|
||||||
// ***********************
|
// ***********************
|
||||||
// Worker
|
// Worker
|
||||||
@@ -145,22 +143,26 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
checksRepository,
|
checksRepository,
|
||||||
incidentsRepository,
|
incidentsRepository,
|
||||||
geoChecksRepository,
|
geoChecksRepository,
|
||||||
dockerLogsRepository
|
dockerLogsRepository,
|
||||||
|
egressService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlers: JobHandlers = {
|
||||||
|
check: pipeline.handleCheck,
|
||||||
|
evaluate: pipeline.handleEvaluate,
|
||||||
|
"geo-check": geoCheckPipeline.handle,
|
||||||
|
"cleanup-orphaned": workerHelper.getCleanupOrphanedJob(),
|
||||||
|
"cleanup-retention": workerHelper.getCleanupRetentionJob(),
|
||||||
|
egress: workerHelper.getEgressRecoveryJob(),
|
||||||
|
};
|
||||||
|
|
||||||
const worker = await DBQueueWorker.create({
|
const worker = await DBQueueWorker.create({
|
||||||
logger,
|
logger,
|
||||||
isDbConnected: () => mongoose.connection.readyState === 1,
|
isDbConnected: () => mongoose.connection.readyState === 1,
|
||||||
jobsRepository,
|
jobsRepository,
|
||||||
monitorsRepository,
|
monitorsRepository,
|
||||||
checksRepository,
|
|
||||||
checkService,
|
|
||||||
bufferService,
|
bufferService,
|
||||||
checkProducer,
|
handlers,
|
||||||
checkEvaluator,
|
|
||||||
geoCheckPipeline,
|
|
||||||
dispatcher: reactorDispatcher,
|
|
||||||
helper: workerHelper,
|
|
||||||
queueWorkersRepository,
|
queueWorkersRepository,
|
||||||
queueMode: envSettings.queueMode,
|
queueMode: envSettings.queueMode,
|
||||||
queuePrimaryProcesses: envSettings.queuePrimaryProcesses,
|
queuePrimaryProcesses: envSettings.queuePrimaryProcesses,
|
||||||
@@ -185,5 +187,5 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { worker, networkService, bufferService, statusService };
|
return { worker, networkService: providerRegistry, bufferService, statusService, egressService };
|
||||||
};
|
};
|
||||||
@@ -6,6 +6,7 @@ export interface ISettingsRepository {
|
|||||||
findSingleton(): Promise<Settings | null>;
|
findSingleton(): Promise<Settings | null>;
|
||||||
// update
|
// update
|
||||||
update(settings: SettingsUpdate): Promise<Settings>;
|
update(settings: SettingsUpdate): Promise<Settings>;
|
||||||
|
removeEgressNotification(notificationId: string): Promise<void>; // $pull, so it cannot race a concurrent settings update
|
||||||
// delete
|
// delete
|
||||||
deleteLegacy: () => Promise<boolean>;
|
deleteLegacy: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Schema, model, type Types } from "mongoose";
|
import { Schema, model, type Types } from "mongoose";
|
||||||
import type { Settings, SettingsThresholds } from "@/domain/app-settings/app-settings.type.js";
|
import type { Settings, SettingsThresholds } from "@/domain/app-settings/app-settings.type.js";
|
||||||
|
import { DEFAULT_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
|
||||||
|
|
||||||
interface AppSettingsDocument extends Omit<Settings, "id" | "createdAt" | "updatedAt"> {
|
interface AppSettingsDocument extends Omit<Settings, "id" | "createdAt" | "updatedAt"> {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
@@ -42,6 +43,9 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
|
|||||||
globalThresholds: { type: thresholdsSchema },
|
globalThresholds: { type: thresholdsSchema },
|
||||||
globalProxyEnabled: { type: Boolean, default: false },
|
globalProxyEnabled: { type: Boolean, default: false },
|
||||||
globalProxyId: { type: String, default: null },
|
globalProxyId: { type: String, default: null },
|
||||||
|
egressCheckEnabled: { type: Boolean, default: false },
|
||||||
|
egressCheckTargets: { type: [String], default: () => [...DEFAULT_EGRESS_TARGETS] },
|
||||||
|
egressNotifications: { type: [String], default: () => [] }, // Notification ids, stored as strings like globalProxyId
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ISettingsRepository } from "@/domain/app-settings/app-settings-reposito
|
|||||||
import type { Settings, SettingsUpdate } from "@/domain/app-settings/app-settings.type.js";
|
import type { Settings, SettingsUpdate } from "@/domain/app-settings/app-settings.type.js";
|
||||||
import { AppSettingsModel, type AppSettingsDocument } from "@/domain/app-settings/app-settings.model.js";
|
import { AppSettingsModel, type AppSettingsDocument } from "@/domain/app-settings/app-settings.model.js";
|
||||||
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
|
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
|
||||||
|
import { DEFAULT_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
|
||||||
|
|
||||||
class MongoSettingsRepository implements ISettingsRepository {
|
class MongoSettingsRepository implements ISettingsRepository {
|
||||||
private toEntity = (doc: AppSettingsDocument): Settings => {
|
private toEntity = (doc: AppSettingsDocument): Settings => {
|
||||||
@@ -30,6 +31,9 @@ class MongoSettingsRepository implements ISettingsRepository {
|
|||||||
globalThresholds: doc.globalThresholds ?? undefined,
|
globalThresholds: doc.globalThresholds ?? undefined,
|
||||||
globalProxyEnabled: doc.globalProxyEnabled ?? false,
|
globalProxyEnabled: doc.globalProxyEnabled ?? false,
|
||||||
globalProxyId: doc.globalProxyId ?? undefined,
|
globalProxyId: doc.globalProxyId ?? undefined,
|
||||||
|
egressCheckEnabled: doc.egressCheckEnabled ?? false,
|
||||||
|
egressCheckTargets: doc.egressCheckTargets ?? [...DEFAULT_EGRESS_TARGETS],
|
||||||
|
egressNotifications: doc.egressNotifications ?? [],
|
||||||
createdAt: toDateString(doc.createdAt),
|
createdAt: toDateString(doc.createdAt),
|
||||||
updatedAt: toDateString(doc.updatedAt),
|
updatedAt: toDateString(doc.updatedAt),
|
||||||
};
|
};
|
||||||
@@ -79,6 +83,10 @@ class MongoSettingsRepository implements ISettingsRepository {
|
|||||||
return this.toEntity(updatedSettings);
|
return this.toEntity(updatedSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
removeEgressNotification = async (notificationId: string) => {
|
||||||
|
await AppSettingsModel.updateMany({ egressNotifications: notificationId }, { $pull: { egressNotifications: notificationId } });
|
||||||
|
};
|
||||||
|
|
||||||
deleteLegacy = async () => {
|
deleteLegacy = async () => {
|
||||||
const res = await AppSettingsModel.deleteMany({ version: { $exists: false } });
|
const res = await AppSettingsModel.deleteMany({ version: { $exists: false } });
|
||||||
return res.deletedCount > 0;
|
return res.deletedCount > 0;
|
||||||
|
|||||||
@@ -23,15 +23,27 @@ export interface ISettingsService {
|
|||||||
getSettings(): EnvConfig;
|
getSettings(): EnvConfig;
|
||||||
areStatusPageThemesEnabled(): boolean;
|
areStatusPageThemesEnabled(): boolean;
|
||||||
getDBSettings(): Promise<Settings>;
|
getDBSettings(): Promise<Settings>;
|
||||||
|
getCachedDBSettings(): Promise<Settings>;
|
||||||
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
|
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
|
||||||
|
removeEgressNotification(notificationId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DB_SETTINGS_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
type CachedSettings = {
|
||||||
|
value: Promise<Settings>;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
export class SettingsService implements ISettingsService {
|
export class SettingsService implements ISettingsService {
|
||||||
static SERVICE_NAME = SERVICE_NAME;
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
private settings: EnvConfig;
|
private settings: EnvConfig;
|
||||||
private settingsRepository: ISettingsRepository | null = null;
|
private settingsRepository: ISettingsRepository | null = null;
|
||||||
|
private dbSettingsCache: CachedSettings | null = null;
|
||||||
|
|
||||||
constructor(env: ValidatedEnv) {
|
constructor(
|
||||||
|
env: ValidatedEnv,
|
||||||
|
private dbSettingsTTLMs: number = DB_SETTINGS_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
this.settings = {
|
this.settings = {
|
||||||
jwtSecret: env.JWT_SECRET,
|
jwtSecret: env.JWT_SECRET,
|
||||||
jwtTTL: env.TOKEN_TTL as StringValue,
|
jwtTTL: env.TOKEN_TTL as StringValue,
|
||||||
@@ -75,7 +87,14 @@ export class SettingsService implements ISettingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateDbSettings = async (newSettings: SettingsUpdate) => {
|
updateDbSettings = async (newSettings: SettingsUpdate) => {
|
||||||
return await this.getRepository().update(newSettings);
|
const updated = await this.getRepository().update(newSettings);
|
||||||
|
this.dbSettingsCache = null;
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
removeEgressNotification = async (notificationId: string) => {
|
||||||
|
await this.getRepository().removeEgressNotification(notificationId);
|
||||||
|
this.dbSettingsCache = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
getDBSettings = async () => {
|
getDBSettings = async () => {
|
||||||
@@ -95,4 +114,17 @@ export class SettingsService implements ISettingsService {
|
|||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getCachedDBSettings = async () => {
|
||||||
|
const hit = this.dbSettingsCache;
|
||||||
|
if (hit && hit.expiresAt > Date.now()) {
|
||||||
|
return hit.value;
|
||||||
|
}
|
||||||
|
const entry: CachedSettings = { value: this.getDBSettings(), expiresAt: Date.now() + this.dbSettingsTTLMs };
|
||||||
|
this.dbSettingsCache = entry;
|
||||||
|
entry.value.catch(() => {
|
||||||
|
if (this.dbSettingsCache === entry) this.dbSettingsCache = null;
|
||||||
|
});
|
||||||
|
return entry.value;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,10 @@ export interface Settings {
|
|||||||
globalThresholds?: SettingsThresholds;
|
globalThresholds?: SettingsThresholds;
|
||||||
globalProxyEnabled: boolean;
|
globalProxyEnabled: boolean;
|
||||||
globalProxyId?: string | null;
|
globalProxyId?: string | null;
|
||||||
|
// Egress self-check (see domain/egress)
|
||||||
|
egressCheckEnabled: boolean;
|
||||||
|
egressCheckTargets: string[];
|
||||||
|
egressNotifications: string[]; // Notification ids to receive the "egress recovered" alert
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import CheckModel from "@/domain/checks/check.model.js";
|
import CheckModel from "@/domain/checks/check.model.js";
|
||||||
import { DockerContainerStatsBucket, DockerStatsBucket } from "@/domain/checks/check.type.js";
|
import { DockerContainerStatsBucket, DockerStatsBucket } from "@/domain/checks/check.type.js";
|
||||||
|
import { EXCLUDE_DEGRADED_EGRESS_MATCH } from "@/domain/checks/check.query.js";
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
type DateRange = { start: Date; end: Date };
|
type DateRange = { start: Date; end: Date };
|
||||||
@@ -9,6 +10,7 @@ export const getDockerTotalChecks = async (monitorId: string, dates: DateRange):
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "docker",
|
"metadata.type": "docker",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDockerUpChecks = async (monitorId: string, dates: DateRange): Promise<{ totalChecks: number }> => {
|
export const getDockerUpChecks = async (monitorId: string, dates: DateRange): Promise<{ totalChecks: number }> => {
|
||||||
@@ -16,6 +18,7 @@ export const getDockerUpChecks = async (monitorId: string, dates: DateRange): Pr
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "docker",
|
"metadata.type": "docker",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
status: true,
|
status: true,
|
||||||
});
|
});
|
||||||
return { totalChecks: count };
|
return { totalChecks: count };
|
||||||
@@ -28,6 +31,7 @@ export const getDockerStats = async (monitorId: string, dates: DateRange, dateSt
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "docker",
|
"metadata.type": "docker",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,6 +52,7 @@ export const getDockerLatestCheck = async (monitorId: string) =>
|
|||||||
CheckModel.findOne({
|
CheckModel.findOne({
|
||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "docker",
|
"metadata.type": "docker",
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
})
|
})
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.select("containers containerSummary createdAt")
|
.select("containers containerSummary createdAt")
|
||||||
@@ -65,6 +70,7 @@ export const getDockerContainerStats = async (
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "docker",
|
"metadata.type": "docker",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
"containers.name": containerName,
|
"containers.name": containerName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -88,6 +94,7 @@ export const getDockerContainerLatestCheck = async (monitorId: string, container
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "docker",
|
"metadata.type": "docker",
|
||||||
"containers.name": containerName,
|
"containers.name": containerName,
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
})
|
})
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.select("containers createdAt")
|
.select("containers createdAt")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CheckModel } from "@/domain/checks/check.model.js";
|
import { CheckModel } from "@/domain/checks/check.model.js";
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import type { HardwareDiskStats, HardwareNetStats } from "@/domain/checks/check.type.js";
|
import type { HardwareDiskStats, HardwareNetStats } from "@/domain/checks/check.type.js";
|
||||||
|
import { EXCLUDE_DEGRADED_EGRESS_MATCH } from "@/domain/checks/check.query.js";
|
||||||
|
|
||||||
type DateRange = { start: Date; end: Date };
|
type DateRange = { start: Date; end: Date };
|
||||||
type HardwareUpChecks = { totalChecks: number };
|
type HardwareUpChecks = { totalChecks: number };
|
||||||
@@ -70,6 +71,7 @@ export const getHardwareTotalChecks = async (monitorId: string, dates: DateRange
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "hardware",
|
"metadata.type": "hardware",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export const getHardwareUpChecks = async (monitorId: string, dates: DateRange):
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "hardware",
|
"metadata.type": "hardware",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
status: true,
|
status: true,
|
||||||
});
|
});
|
||||||
return { totalChecks: count };
|
return { totalChecks: count };
|
||||||
@@ -95,6 +98,7 @@ export const getHardwareStats = async (monitorId: string, dates: DateRange, date
|
|||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
"metadata.type": "hardware",
|
"metadata.type": "hardware",
|
||||||
createdAt: { $gte: dates.start, $lte: dates.end },
|
createdAt: { $gte: dates.start, $lte: dates.end },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ $sort: { createdAt: 1 } },
|
{ $sort: { createdAt: 1 } },
|
||||||
|
|||||||
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user