Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a66cf6e11 | ||
|
|
41c7cfbdaa | ||
|
|
7fb57d8090 | ||
|
|
618258d282 | ||
|
|
e50568dba3 | ||
|
|
5fa894cb79 | ||
|
|
6d4e944a58 | ||
|
|
cfe7d2a359 | ||
|
|
5fb26367ed | ||
|
|
cd65488cbd | ||
|
|
4ea6539a7d | ||
|
|
a75bb5fae6 | ||
|
|
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 | ||
|
|
2632f98ad0 | ||
|
|
b2ace1bec5 |
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { dashboardCardKeys, type DashboardCardKey } from "@/Types/Dashboard";
|
||||||
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 =
|
||||||
| "monitors"
|
| "monitors"
|
||||||
| "team"
|
| "team"
|
||||||
@@ -35,6 +37,7 @@ interface UIState {
|
|||||||
language: string;
|
language: string;
|
||||||
starPromptOpen: boolean;
|
starPromptOpen: boolean;
|
||||||
chartType: ChartType;
|
chartType: ChartType;
|
||||||
|
dashboardVisibleCards: DashboardCardKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
|
||||||
@@ -74,6 +77,7 @@ const initialState: UIState = {
|
|||||||
language: "en",
|
language: "en",
|
||||||
starPromptOpen: true,
|
starPromptOpen: true,
|
||||||
chartType: "histogram",
|
chartType: "histogram",
|
||||||
|
dashboardVisibleCards: [...dashboardCardKeys],
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiSlice = createSlice({
|
const uiSlice = createSlice({
|
||||||
@@ -117,6 +121,9 @@ 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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,4 +140,5 @@ export const {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
setStarPromptOpen,
|
setStarPromptOpen,
|
||||||
setChartType,
|
setChartType,
|
||||||
|
setDashboardVisibleCards,
|
||||||
} = 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":
|
||||||
|
|||||||
@@ -51,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";
|
||||||
@@ -350,6 +351,7 @@ 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;
|
||||||
@@ -380,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]
|
||||||
@@ -608,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")}
|
||||||
@@ -709,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")}
|
||||||
@@ -719,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"
|
||||||
@@ -743,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")}
|
||||||
@@ -801,7 +814,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStep(1) && (
|
{showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.createMonitor.form.frequency.title")}
|
title={t("pages.createMonitor.form.frequency.title")}
|
||||||
@@ -822,7 +834,6 @@ const CreateMonitorPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert Thresholds - only for hardware type */}
|
{/* Alert Thresholds - only for hardware type */}
|
||||||
{watchedType === "hardware" && showStep(1) && (
|
{watchedType === "hardware" && showStep(1) && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
@@ -878,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")}
|
||||||
@@ -905,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")}
|
||||||
@@ -918,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")}
|
||||||
@@ -945,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")}
|
||||||
@@ -958,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" ||
|
||||||
@@ -974,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")}
|
||||||
@@ -1074,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")}
|
||||||
@@ -1107,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,69 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Checkbox, Dialog } from "@/Components/inputs";
|
||||||
|
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 { t } = useTranslation();
|
||||||
|
const [draft, setDraft] = useState<Set<DashboardCardKey>>(new Set(visibleCards));
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setDraft(new Set(visibleCards));
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (key: DashboardCardKey) => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={t("pages.dashboard.editCardsModal.title")}
|
||||||
|
content={t("pages.dashboard.editCardsModal.description")}
|
||||||
|
onCancel={handleClose}
|
||||||
|
onConfirm={() => onSave(draft)}
|
||||||
|
confirmText={t("pages.dashboard.editCardsModal.done")}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{dashboardCardKeys.map((key: DashboardCardKey) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={key}
|
||||||
|
label={t(`pages.dashboard.cards.${key}`)}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.has(key)}
|
||||||
|
disabled={draft.has(key) && draft.size === 1}
|
||||||
|
onChange={() => handleToggle(key)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,41 +9,50 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { MonitorsSummary } from "@/Types/Monitor";
|
import type { MonitorsSummary } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface MonitorStatusCardProps {
|
interface MonitorStatusCardProps {
|
||||||
summary: MonitorsSummary;
|
summary: MonitorsSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
|
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const up = summary.upMonitors;
|
const up = summary?.upMonitors ?? 0;
|
||||||
const total = summary.totalMonitors;
|
const total = summary?.totalMonitors ?? 0;
|
||||||
const ratio = total > 0 ? up / total : 0;
|
const ratio = total > 0 ? up / total : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
|
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
|
||||||
<Stack gap={theme.spacing(SPACING.SM)}>
|
{total === 0 ? (
|
||||||
<Stack
|
<Typography
|
||||||
direction="row"
|
fontSize={typographyLevels.m}
|
||||||
alignItems="baseline"
|
color={theme.palette.text.disabled}
|
||||||
gap={theme.spacing(SPACING.XS)}
|
|
||||||
>
|
>
|
||||||
<Typography
|
{t("pages.dashboard.noData")}
|
||||||
fontSize={typographyLevels.xxl}
|
</Typography>
|
||||||
fontWeight={500}
|
) : (
|
||||||
color={theme.palette.text.primary}
|
<Stack gap={theme.spacing(SPACING.SM)}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="baseline"
|
||||||
|
gap={theme.spacing(SPACING.XS)}
|
||||||
>
|
>
|
||||||
{up}
|
<Typography
|
||||||
</Typography>
|
fontSize={typographyLevels.xxl}
|
||||||
<Typography
|
fontWeight={500}
|
||||||
fontSize={typographyLevels.m}
|
color={theme.palette.text.primary}
|
||||||
color={theme.palette.text.secondary}
|
>
|
||||||
>
|
{up}
|
||||||
{t("pages.dashboard.monitorStatus.ofUp", { total })}
|
</Typography>
|
||||||
</Typography>
|
<Typography
|
||||||
|
fontSize={typographyLevels.m}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{t("pages.dashboard.monitorStatus.ofUp", { total })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<ProgressBar fillRatio={ratio} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<ProgressBar fillRatio={ratio} />
|
)}
|
||||||
</Stack>
|
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
|
||||||
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
|
import type { MonitorTypeCount } from "@/Types/Monitor";
|
||||||
import { getMonitorTypeLabel } from "@/Types/Monitor";
|
import { getMonitorTypeLabel } from "@/Types/Monitor";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
|
||||||
interface MonitorsByTypeCardProps {
|
interface MonitorsByTypeCardProps {
|
||||||
monitorsByType: DashboardTypeCount[];
|
monitorsByType: MonitorTypeCount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
|
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
|
||||||
@@ -19,17 +21,26 @@ export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
|
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
|
||||||
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
{monitorsByType.length === 0 ? (
|
||||||
{monitorsByType.map(({ type, count }) => (
|
<Typography
|
||||||
<BarRow
|
fontSize={typographyLevels.m}
|
||||||
key={type}
|
color={theme.palette.text.disabled}
|
||||||
label={getMonitorTypeLabel(type, t)}
|
>
|
||||||
value={String(count)}
|
{t("pages.dashboard.noData")}
|
||||||
fillRatio={count / maxCount}
|
</Typography>
|
||||||
valueColor={theme.palette.text.primary}
|
) : (
|
||||||
/>
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
))}
|
{monitorsByType.map(({ type, count }) => (
|
||||||
</Stack>
|
<BarRow
|
||||||
|
key={type}
|
||||||
|
label={getMonitorTypeLabel(type, t)}
|
||||||
|
value={String(count)}
|
||||||
|
fillRatio={count / maxCount}
|
||||||
|
valueColor={theme.palette.text.primary}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,43 +1,94 @@
|
|||||||
|
import { Fragment, useMemo, useState, type ReactNode } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTheme } from "@mui/material/styles";
|
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 { BasePage } from "@/Components/design-elements/BasePage";
|
||||||
|
import { Button } from "@/Components/inputs";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
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 { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
|
||||||
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
|
||||||
import type { MonitorsSummary } from "@/Types/Monitor";
|
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
|
||||||
import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
|
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
|
||||||
|
|
||||||
const MOCK_SUMMARY: MonitorsSummary = {
|
|
||||||
totalMonitors: 12,
|
|
||||||
upMonitors: 10,
|
|
||||||
downMonitors: 1,
|
|
||||||
pausedMonitors: 1,
|
|
||||||
initializingMonitors: 0,
|
|
||||||
maintenanceMonitors: 0,
|
|
||||||
breachedMonitors: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_MONITORS_BY_TYPE: DashboardTypeCount[] = [
|
|
||||||
{ type: "http", count: 7 },
|
|
||||||
{ type: "ping", count: 3 },
|
|
||||||
{ type: "port", count: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const theme = useTheme();
|
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: "monitors",
|
||||||
|
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} />,
|
||||||
|
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BasePage headerKey="dashboard">
|
<BasePage headerKey="dashboard">
|
||||||
<Box
|
<Stack gap={theme.spacing(LAYOUT.SM)}>
|
||||||
display="grid"
|
<Stack
|
||||||
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
|
direction="row"
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
justifyContent="flex-end"
|
||||||
>
|
>
|
||||||
<MonitorStatusCard summary={MOCK_SUMMARY} />
|
<Button
|
||||||
<MonitorsByTypeCard monitorsByType={MOCK_MONITORS_BY_TYPE} />
|
variant="outlined"
|
||||||
</Box>
|
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>
|
</BasePage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { MonitorType } from "@/Types/Monitor";
|
|
||||||
|
|
||||||
export interface DashboardTypeCount {
|
|
||||||
type: MonitorType;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
@@ -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>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 || []}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const;
|
||||||
|
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
|
||||||
@@ -169,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;
|
||||||
@@ -192,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[];
|
||||||
|
|||||||
@@ -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 = "—";
|
||||||
@@ -20,7 +20,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
|
||||||
|
|||||||
@@ -110,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(),
|
||||||
@@ -135,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
|
||||||
@@ -250,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": {
|
||||||
|
|||||||
@@ -1378,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": {
|
||||||
|
|||||||
@@ -557,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",
|
||||||
@@ -720,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.",
|
||||||
@@ -827,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.",
|
||||||
@@ -1685,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"
|
||||||
@@ -1712,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",
|
||||||
@@ -1911,6 +1930,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
"noData": "No data available at this moment.",
|
||||||
"monitorStatus": {
|
"monitorStatus": {
|
||||||
"ofUp": "of {{total}} up"
|
"ofUp": "of {{total}} up"
|
||||||
},
|
},
|
||||||
@@ -1921,6 +1941,12 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"description": "See every monitor type in one place and spot what needs attention."
|
"description": "See every monitor type in one place and spot what needs attention."
|
||||||
|
},
|
||||||
|
"editCards": "Edit cards",
|
||||||
|
"editCardsModal": {
|
||||||
|
"title": "Edit cards",
|
||||||
|
"description": "Select the cards to display on your dashboard.",
|
||||||
|
"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": {
|
||||||
|
|||||||
+52
-5
@@ -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": [
|
||||||
{
|
{
|
||||||
@@ -13307,11 +13359,6 @@
|
|||||||
},
|
},
|
||||||
"maxItems": 10
|
"maxItems": 10
|
||||||
},
|
},
|
||||||
"egressPollIntervalSeconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 5,
|
|
||||||
"maximum": 600
|
|
||||||
},
|
|
||||||
"egressNotifications": {
|
"egressNotifications": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
Generated
+7
@@ -18,6 +18,7 @@
|
|||||||
"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": "^4.19.2",
|
||||||
@@ -5803,6 +5804,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||||
|
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
+2
-1
@@ -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 .",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"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": "^4.19.2",
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { IProxiesService } from "@/domain/proxies/proxy.service.js";
|
|||||||
import { IEgressStateService } from "@/domain/egress/egress-state.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;
|
||||||
@@ -119,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 {
|
||||||
@@ -142,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);
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
|
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
|
||||||
import { MAX_EGRESS_POLL_INTERVAL_SECONDS, MAX_EGRESS_TARGETS, MIN_EGRESS_POLL_INTERVAL_SECONDS } from "@/domain/egress/egress.type.js";
|
import { MAX_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
|
||||||
import { parseEgressTarget } from "@/utils/egressTarget.js";
|
import { parseEgressTarget } from "@/utils/egressTarget.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -56,7 +56,6 @@ export const updateAppSettingsBodyValidation = z
|
|||||||
|
|
||||||
egressCheckEnabled: z.boolean().optional(),
|
egressCheckEnabled: z.boolean().optional(),
|
||||||
egressCheckTargets: z.array(egressTargetValidation).max(MAX_EGRESS_TARGETS).optional(), // empty list falls back to DEFAULT_EGRESS_TARGETS
|
egressCheckTargets: z.array(egressTargetValidation).max(MAX_EGRESS_TARGETS).optional(), // empty list falls back to DEFAULT_EGRESS_TARGETS
|
||||||
egressPollIntervalSeconds: z.number().int().min(MIN_EGRESS_POLL_INTERVAL_SECONDS).max(MAX_EGRESS_POLL_INTERVAL_SECONDS).optional(),
|
|
||||||
egressNotifications: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid notification id")).optional(),
|
egressNotifications: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid notification id")).optional(),
|
||||||
})
|
})
|
||||||
.strip()
|
.strip()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ 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);
|
||||||
|
|||||||
@@ -17,12 +17,9 @@ import { INetworkService, NetworkService } from "@/service/networkService.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";
|
||||||
@@ -42,6 +39,7 @@ import { GrpcProvider } from "@/service/network/GrpcProvider.js";
|
|||||||
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
|
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
|
||||||
import { DNSProvider } from "@/service/network/DNSProvider.js";
|
import { DNSProvider } from "@/service/network/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: INetworkService;
|
||||||
@@ -82,7 +80,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
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);
|
||||||
@@ -103,36 +101,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, networkService, proxyResolver, logger);
|
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, networkService, 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,
|
networkService,
|
||||||
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
|
||||||
@@ -153,19 +148,22 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
|||||||
egressService
|
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,
|
||||||
|
|||||||
@@ -1,6 +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_POLL_INTERVAL_SECONDS, DEFAULT_EGRESS_TARGETS } from "@/domain/egress/egress.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;
|
||||||
@@ -45,7 +45,6 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
|
|||||||
globalProxyId: { type: String, default: null },
|
globalProxyId: { type: String, default: null },
|
||||||
egressCheckEnabled: { type: Boolean, default: false },
|
egressCheckEnabled: { type: Boolean, default: false },
|
||||||
egressCheckTargets: { type: [String], default: () => [...DEFAULT_EGRESS_TARGETS] },
|
egressCheckTargets: { type: [String], default: () => [...DEFAULT_EGRESS_TARGETS] },
|
||||||
egressPollIntervalSeconds: { type: Number, default: DEFAULT_EGRESS_POLL_INTERVAL_SECONDS },
|
|
||||||
egressNotifications: { type: [String], default: () => [] }, // Notification ids, stored as strings like globalProxyId
|
egressNotifications: { type: [String], default: () => [] }, // Notification ids, stored as strings like globalProxyId
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
|
|||||||
@@ -3,7 +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_POLL_INTERVAL_SECONDS, DEFAULT_EGRESS_TARGETS } from "@/domain/egress/egress.type.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 => {
|
||||||
@@ -33,7 +33,6 @@ class MongoSettingsRepository implements ISettingsRepository {
|
|||||||
globalProxyId: doc.globalProxyId ?? undefined,
|
globalProxyId: doc.globalProxyId ?? undefined,
|
||||||
egressCheckEnabled: doc.egressCheckEnabled ?? false,
|
egressCheckEnabled: doc.egressCheckEnabled ?? false,
|
||||||
egressCheckTargets: doc.egressCheckTargets ?? [...DEFAULT_EGRESS_TARGETS],
|
egressCheckTargets: doc.egressCheckTargets ?? [...DEFAULT_EGRESS_TARGETS],
|
||||||
egressPollIntervalSeconds: doc.egressPollIntervalSeconds ?? DEFAULT_EGRESS_POLL_INTERVAL_SECONDS,
|
|
||||||
egressNotifications: doc.egressNotifications ?? [],
|
egressNotifications: doc.egressNotifications ?? [],
|
||||||
createdAt: toDateString(doc.createdAt),
|
createdAt: toDateString(doc.createdAt),
|
||||||
updatedAt: toDateString(doc.updatedAt),
|
updatedAt: toDateString(doc.updatedAt),
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export interface Settings {
|
|||||||
// Egress self-check (see domain/egress)
|
// Egress self-check (see domain/egress)
|
||||||
egressCheckEnabled: boolean;
|
egressCheckEnabled: boolean;
|
||||||
egressCheckTargets: string[];
|
egressCheckTargets: string[];
|
||||||
egressPollIntervalSeconds: number; // recovery poll cadence while degraded, not a general schedule
|
|
||||||
egressNotifications: string[]; // Notification ids to receive the "egress recovered" alert
|
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 } },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Schema, model, Types } from "mongoose";
|
import { Schema, model, Types } from "mongoose";
|
||||||
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
|
import { EgressStatuses } from "@/domain/egress/egress.type.js";
|
||||||
import type {
|
import type {
|
||||||
Check,
|
Check,
|
||||||
CheckAudits,
|
CheckAudits,
|
||||||
@@ -219,6 +220,7 @@ const dockerContainerSchema = new Schema<DockerContainerInfo>(
|
|||||||
startedAt: { type: String },
|
startedAt: { type: String },
|
||||||
ports: { type: [dockerContainerPortSchema], default: undefined },
|
ports: { type: [dockerContainerPortSchema], default: undefined },
|
||||||
mounts: { type: [dockerContainerMountSchema], default: undefined },
|
mounts: { type: [dockerContainerMountSchema], default: undefined },
|
||||||
|
exitCode: { type: Number, default: undefined },
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
@@ -337,6 +339,11 @@ const CheckSchema = new Schema<CheckDocument>(
|
|||||||
type: containerSummarySchema,
|
type: containerSummarySchema,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
|
egressStatus: {
|
||||||
|
type: String,
|
||||||
|
enum: EgressStatuses,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { EgressStatus } from "@/domain/egress/egress.type.js";
|
||||||
|
|
||||||
|
// A check recorded while the instance's own egress was down says nothing about the target, so it is left out of
|
||||||
|
// every aggregate: uptime percentages, up/down counts and the response-time series alike. A degraded episode
|
||||||
|
// therefore reads as a gap in monitoring rather than as downtime or as a dip in response time.
|
||||||
|
// Spread this into the $match or filter of any query that reports statistics. Check listings keep degraded checks.
|
||||||
|
export const EXCLUDE_DEGRADED_EGRESS_MATCH = { egressStatus: { $ne: "degraded" satisfies EgressStatus } } as const;
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
PageSpeedChecksResult,
|
PageSpeedChecksResult,
|
||||||
UptimeChecksResult,
|
UptimeChecksResult,
|
||||||
} from "@/domain/checks/check.type.js";
|
} from "@/domain/checks/check.type.js";
|
||||||
|
import { PendingCheck } from "@/domain/jobs/job.type.js";
|
||||||
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { CheckFilter, DateRange } from "@/types/query.js";
|
import { CheckFilter, DateRange } from "@/types/query.js";
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export interface IChecksRepository {
|
|||||||
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
|
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
|
||||||
findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise<Omit<DockerContainerStats, "restartsInRange">>;
|
findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise<Omit<DockerContainerStats, "restartsInRange">>;
|
||||||
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
|
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
|
||||||
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
|
findUnevaluatedByMonitorId(monitorId: string, pending: PendingCheck[]): Promise<Check[]>;
|
||||||
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
|
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
|
||||||
|
|
||||||
// update
|
// update
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
} from "@/domain/checks/check.type.js";
|
} from "@/domain/checks/check.type.js";
|
||||||
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js";
|
import { CheckModel, type CheckDocument } from "@/domain/checks/check.model.js";
|
||||||
|
import { EXCLUDE_DEGRADED_EGRESS_MATCH } from "@/domain/checks/check.query.js";
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { getDateFormat, getDateForRange } from "@/utils/dataUtils.js";
|
import { getDateFormat, getDateForRange } from "@/utils/dataUtils.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
getDockerTotalChecks,
|
getDockerTotalChecks,
|
||||||
getDockerUpChecks,
|
getDockerUpChecks,
|
||||||
} from "@/domain/checks/check.docker.aggregation.js";
|
} from "@/domain/checks/check.docker.aggregation.js";
|
||||||
|
import { PendingCheck } from "@/domain/jobs/job.type.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "ChecksRepository";
|
const SERVICE_NAME = "ChecksRepository";
|
||||||
|
|
||||||
@@ -178,6 +180,7 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
audits: mapAudits(doc.audits),
|
audits: mapAudits(doc.audits),
|
||||||
containers: doc.containers,
|
containers: doc.containers,
|
||||||
containerSummary: doc.containerSummary,
|
containerSummary: doc.containerSummary,
|
||||||
|
egressStatus: doc.egressStatus,
|
||||||
createdAt: toDateString(doc.createdAt),
|
createdAt: toDateString(doc.createdAt),
|
||||||
updatedAt: toDateString(doc.updatedAt),
|
updatedAt: toDateString(doc.updatedAt),
|
||||||
};
|
};
|
||||||
@@ -347,6 +350,7 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
const baseMatch = {
|
const baseMatch = {
|
||||||
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
|
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
|
||||||
createdAt: { $gte: getDateForRange(dateRange) },
|
createdAt: { $gte: getDateForRange(dateRange) },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [totalResult, downResult] = await Promise.all([
|
const [totalResult, downResult] = await Promise.all([
|
||||||
@@ -360,10 +364,23 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
findUnevaluatedByMonitorId = async (monitorId: string, since: number) => {
|
findUnevaluatedByMonitorId = async (monitorId: string, pending: PendingCheck[]) => {
|
||||||
|
if (pending.length === 0) return [];
|
||||||
|
|
||||||
|
const ids = pending.map((entry) => new mongoose.Types.ObjectId(entry.checkId));
|
||||||
|
|
||||||
|
// Bound the query to the pending time range so the monitorId + createdAt index is used
|
||||||
|
let from = Number.POSITIVE_INFINITY;
|
||||||
|
let to = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const entry of pending) {
|
||||||
|
if (entry.createdAt < from) from = entry.createdAt;
|
||||||
|
if (entry.createdAt > to) to = entry.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
const docs = await CheckModel.find({
|
const docs = await CheckModel.find({
|
||||||
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
|
||||||
createdAt: { $gt: new Date(since) },
|
createdAt: { $gte: new Date(from), $lte: new Date(to) },
|
||||||
|
_id: { $in: ids },
|
||||||
})
|
})
|
||||||
.sort({ createdAt: 1 })
|
.sort({ createdAt: 1 })
|
||||||
.lean<CheckDocument[]>();
|
.lean<CheckDocument[]>();
|
||||||
@@ -379,6 +396,7 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
$match: {
|
$match: {
|
||||||
"metadata.monitorId": { $in: objectIds },
|
"metadata.monitorId": { $in: objectIds },
|
||||||
createdAt: { $gte: windowStart },
|
createdAt: { $gte: windowStart },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -459,6 +477,7 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
const matchStage = {
|
const matchStage = {
|
||||||
"metadata.monitorId": monitorObjectId,
|
"metadata.monitorId": monitorObjectId,
|
||||||
createdAt: { $gte: startDate, $lte: endDate },
|
createdAt: { $gte: startDate, $lte: endDate },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
};
|
};
|
||||||
const [result] = await CheckModel.aggregate([
|
const [result] = await CheckModel.aggregate([
|
||||||
{ $match: matchStage },
|
{ $match: matchStage },
|
||||||
@@ -624,6 +643,7 @@ class MongoChecksRepository implements IChecksRepository {
|
|||||||
const matchStage = {
|
const matchStage = {
|
||||||
"metadata.monitorId": monitorObjectId,
|
"metadata.monitorId": monitorObjectId,
|
||||||
createdAt: { $gte: startDate, $lte: endDate },
|
createdAt: { $gte: startDate, $lte: endDate },
|
||||||
|
...EXCLUDE_DEGRADED_EGRESS_MATCH,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [result] = await CheckModel.aggregate([
|
const [result] = await CheckModel.aggregate([
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const SERVICE_NAME = "checkService";
|
|||||||
|
|
||||||
export interface ICheckService {
|
export interface ICheckService {
|
||||||
createChecks(checks: Check[]): Promise<Check[]>;
|
createChecks(checks: Check[]): Promise<Check[]>;
|
||||||
toLastEvaluatedAt(check: Check): number;
|
|
||||||
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
|
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
|
||||||
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
|
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
|
||||||
getChecksByMonitor(params: {
|
getChecksByMonitor(params: {
|
||||||
@@ -55,10 +54,6 @@ export class CheckService implements ICheckService {
|
|||||||
return this.checksRepository.createChecks(checks);
|
return this.checksRepository.createChecks(checks);
|
||||||
};
|
};
|
||||||
|
|
||||||
toLastEvaluatedAt = (check: Check) => {
|
|
||||||
return new Date(check.createdAt).getTime();
|
|
||||||
};
|
|
||||||
|
|
||||||
toCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
|
toCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
|
||||||
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
|
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
|
||||||
|
|
||||||
@@ -140,6 +135,17 @@ export class CheckService implements ICheckService {
|
|||||||
return check;
|
return check;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private toPayload = (check: Check): HardwareStatusPayload | DockerStatusPayload | undefined => {
|
||||||
|
switch (check.metadata.type) {
|
||||||
|
case "hardware":
|
||||||
|
return { data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload;
|
||||||
|
case "docker":
|
||||||
|
return check.containers ? { containers: check.containers, summary: check.containerSummary } : undefined;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
toStatusResponse = (check: Check) => {
|
toStatusResponse = (check: Check) => {
|
||||||
const statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]> = {
|
const statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]> = {
|
||||||
monitorId: check.metadata.monitorId,
|
monitorId: check.metadata.monitorId,
|
||||||
@@ -150,10 +156,7 @@ export class CheckService implements ICheckService {
|
|||||||
message: check.message,
|
message: check.message,
|
||||||
responseTime: check.responseTime,
|
responseTime: check.responseTime,
|
||||||
// re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data
|
// re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data
|
||||||
payload:
|
payload: this.toPayload(check),
|
||||||
check.metadata.type === "hardware"
|
|
||||||
? ({ data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return statusResponse;
|
return statusResponse;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
|
import type { EgressStatus } from "@/domain/egress/egress.type.js";
|
||||||
import { DockerContainerInfo, DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
import { DockerContainerInfo, DockerContainerSummary } from "@/domain/docker/docker.type.js";
|
||||||
import type { Response } from "got";
|
import type { Response } from "got";
|
||||||
|
|
||||||
@@ -116,6 +117,10 @@ export interface Check {
|
|||||||
seo?: number;
|
seo?: number;
|
||||||
performance?: number;
|
performance?: number;
|
||||||
audits?: CheckAudits;
|
audits?: CheckAudits;
|
||||||
|
// Set on failing checks while the egress check is enabled; absent otherwise.
|
||||||
|
// "degraded": no reliability target was reachable (or the instance was already degraded), so the failure is not attributable to the target.
|
||||||
|
// "ok": a reliability target was reachable.
|
||||||
|
egressStatus?: EgressStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { DockerContainerInfo } from "@/domain/docker/docker.type.js";
|
||||||
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
|
|
||||||
|
export const DockerAlertReasons = ["stopped", "unhealthy"] as const;
|
||||||
|
export type DockerAlertReason = (typeof DockerAlertReasons)[number];
|
||||||
|
|
||||||
|
export interface DockerContainerBreach {
|
||||||
|
name: string;
|
||||||
|
reason: DockerAlertReason;
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isContainerStopped = (container: DockerContainerInfo): boolean => {
|
||||||
|
// Container with exit code 0 is probably a one shot container don't report stopped
|
||||||
|
return container.state === "dead" || (container.state === "exited" && container.exitCode !== 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isContainerUnhealthy = (container: DockerContainerInfo): boolean => {
|
||||||
|
return container.health === "unhealthy";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findContainerBreaches = (monitor: Monitor, containers: DockerContainerInfo[]): DockerContainerBreach[] => {
|
||||||
|
const breaches: DockerContainerBreach[] = [];
|
||||||
|
for (const container of containers) {
|
||||||
|
if (monitor.dockerAlertOnStopped && isContainerStopped(container)) {
|
||||||
|
breaches.push({
|
||||||
|
name: container.name,
|
||||||
|
reason: "stopped",
|
||||||
|
exitCode: container.exitCode,
|
||||||
|
});
|
||||||
|
} else if (monitor.dockerAlertOnUnhealthy && isContainerUnhealthy(container)) {
|
||||||
|
breaches.push({
|
||||||
|
name: container.name,
|
||||||
|
reason: "unhealthy",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return breaches;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeContainerBreach = (breach: DockerContainerBreach): string => {
|
||||||
|
if (breach.reason === "unhealthy") return `${breach.name}: unhealthy`;
|
||||||
|
if (breach.exitCode === undefined) {
|
||||||
|
return `${breach.name}: stopped`;
|
||||||
|
}
|
||||||
|
return `${breach.name}: stopped (exit code ${breach.exitCode})`;
|
||||||
|
};
|
||||||
@@ -55,6 +55,7 @@ export interface DockerContainerInfo {
|
|||||||
startedAt?: string; // ISO date
|
startedAt?: string; // ISO date
|
||||||
ports?: DockerContainerPort[];
|
ports?: DockerContainerPort[];
|
||||||
mounts?: DockerContainerMount[];
|
mounts?: DockerContainerMount[];
|
||||||
|
exitCode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DockerContainerSummary {
|
export interface DockerContainerSummary {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { INetworkService } from "@/service/networkService.js";
|
|||||||
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EGRESS_POLL_INTERVAL_SECONDS,
|
EGRESS_RECOVERY_POLL_SECONDS,
|
||||||
DEFAULT_EGRESS_TARGETS,
|
DEFAULT_EGRESS_TARGETS,
|
||||||
type EgressAssessment,
|
type EgressAssessment,
|
||||||
type EgressProbeResult,
|
type EgressProbeResult,
|
||||||
@@ -26,7 +26,7 @@ const RECOVERY_JOB_TYPE = "egress" as const;
|
|||||||
const RECOVERY_JOB_ID = jobId(RECOVERY_JOB_TYPE, null);
|
const RECOVERY_JOB_ID = jobId(RECOVERY_JOB_TYPE, null);
|
||||||
|
|
||||||
// HttpProvider reports transport failures with NETWORK_ERROR (outside the HTTP range) and everything else with the real status code.
|
// HttpProvider reports transport failures with NETWORK_ERROR (outside the HTTP range) and everything else with the real status code.
|
||||||
const isHttpStatusCode = (code: unknown): boolean => typeof code === "number" && code >= 100 && code <= 599;
|
export const isHttpStatusCode = (code: unknown): boolean => typeof code === "number" && code >= 100 && code <= 599;
|
||||||
|
|
||||||
// Synthetic identity stamped on the probe monitors so provider responses are recognisable in logs.
|
// Synthetic identity stamped on the probe monitors so provider responses are recognisable in logs.
|
||||||
const PROBE_MONITOR_ID = "egress-probe";
|
const PROBE_MONITOR_ID = "egress-probe";
|
||||||
@@ -136,11 +136,6 @@ export class EgressService implements IEgressService {
|
|||||||
return targets.length > 0 ? targets : [...DEFAULT_EGRESS_TARGETS];
|
return targets.length > 0 ? targets : [...DEFAULT_EGRESS_TARGETS];
|
||||||
};
|
};
|
||||||
|
|
||||||
private toIntervalMs = (seconds: number | undefined): number => {
|
|
||||||
const valid = typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0;
|
|
||||||
return (valid ? seconds : DEFAULT_EGRESS_POLL_INTERVAL_SECONDS) * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ****************************
|
// ****************************
|
||||||
// Event-triggered assessment
|
// Event-triggered assessment
|
||||||
// ****************************
|
// ****************************
|
||||||
@@ -181,7 +176,7 @@ export class EgressService implements IEgressService {
|
|||||||
const state = await this.egressStateRepository.findSingleton();
|
const state = await this.egressStateRepository.findSingleton();
|
||||||
if (state.status === "degraded") {
|
if (state.status === "degraded") {
|
||||||
// Recovery is detected by the scheduled job. Make sure the row exists without touching its schedule.
|
// Recovery is detected by the scheduled job. Make sure the row exists without touching its schedule.
|
||||||
await this.scheduleRecoveryCheck(settings.egressPollIntervalSeconds, false);
|
await this.scheduleRecoveryCheck(false);
|
||||||
return "degraded";
|
return "degraded";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +190,7 @@ export class EgressService implements IEgressService {
|
|||||||
|
|
||||||
// Schedule the recovery job before recording the transition: a crash between the two then leaves a stray row
|
// Schedule the recovery job before recording the transition: a crash between the two then leaves a stray row
|
||||||
// while ok, which the job's first run removes, rather than a degraded state that nothing is polling.
|
// while ok, which the job's first run removes, rather than a degraded state that nothing is polling.
|
||||||
await this.scheduleRecoveryCheck(settings.egressPollIntervalSeconds, true);
|
await this.scheduleRecoveryCheck(true);
|
||||||
const degraded = await this.egressStateRepository.markDegraded(results, now);
|
const degraded = await this.egressStateRepository.markDegraded(results, now);
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: degraded
|
message: degraded
|
||||||
@@ -224,8 +219,8 @@ export class EgressService implements IEgressService {
|
|||||||
|
|
||||||
// reschedule=true sets nextScheduledAt (start of an episode); false only inserts the row if it is missing,
|
// reschedule=true sets nextScheduledAt (start of an episode); false only inserts the row if it is missing,
|
||||||
// so re-arming while degraded never pushes a pending run back.
|
// so re-arming while degraded never pushes a pending run back.
|
||||||
private scheduleRecoveryCheck = async (pollIntervalSeconds: number | undefined, reschedule: boolean) => {
|
private scheduleRecoveryCheck = async (reschedule: boolean) => {
|
||||||
const intervalMs = this.toIntervalMs(pollIntervalSeconds);
|
const intervalMs = EGRESS_RECOVERY_POLL_SECONDS * 1000;
|
||||||
const seed: JobSeed = {
|
const seed: JobSeed = {
|
||||||
id: RECOVERY_JOB_ID,
|
id: RECOVERY_JOB_ID,
|
||||||
type: RECOVERY_JOB_TYPE,
|
type: RECOVERY_JOB_TYPE,
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ export type EgressAssessment = EgressStatus | "disabled";
|
|||||||
// Sensible defaults for a fresh install. Bare hosts are ICMP-pinged, host:port is a TCP connect,
|
// Sensible defaults for a fresh install. Bare hosts are ICMP-pinged, host:port is a TCP connect,
|
||||||
// http(s):// URLs are fetched. Any one target being reachable means egress is fine.
|
// http(s):// URLs are fetched. Any one target being reachable means egress is fine.
|
||||||
export const DEFAULT_EGRESS_TARGETS = ["1.1.1.1", "8.8.8.8"] as const;
|
export const DEFAULT_EGRESS_TARGETS = ["1.1.1.1", "8.8.8.8"] as const;
|
||||||
export const DEFAULT_EGRESS_POLL_INTERVAL_SECONDS = 30;
|
|
||||||
export const MIN_EGRESS_POLL_INTERVAL_SECONDS = 5;
|
|
||||||
export const MAX_EGRESS_POLL_INTERVAL_SECONDS = 600;
|
|
||||||
export const MAX_EGRESS_TARGETS = 10;
|
export const MAX_EGRESS_TARGETS = 10;
|
||||||
|
|
||||||
|
// While the instance is marked degraded, every failing check is flagged from stored state without probing, so
|
||||||
|
// this interval is the window in which a recovered instance still discards its checks. It is deliberately fixed
|
||||||
|
// and not configurable: the only effect of raising it is a longer blind window.
|
||||||
|
export const EGRESS_RECOVERY_POLL_SECONDS = 5;
|
||||||
|
|
||||||
// Instance-global singleton persisted in MongoDB so that every worker process and the API
|
// Instance-global singleton persisted in MongoDB so that every worker process and the API
|
||||||
// process share one view of the state (the queue worker may run separately from the API).
|
// process share one view of the state (the queue worker may run separately from the API).
|
||||||
export interface EgressState {
|
export interface EgressState {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ IncidentSchema.index({ teamId: 1, startTime: -1 });
|
|||||||
IncidentSchema.index({ status: 1, startTime: -1 });
|
IncidentSchema.index({ status: 1, startTime: -1 });
|
||||||
IncidentSchema.index({ resolutionType: 1, status: 1 });
|
IncidentSchema.index({ resolutionType: 1, status: 1 });
|
||||||
IncidentSchema.index({ resolvedBy: 1, status: 1 });
|
IncidentSchema.index({ resolvedBy: 1, status: 1 });
|
||||||
|
IncidentSchema.index({ monitorId: 1, startTime: -1 });
|
||||||
IncidentSchema.index({ createdAt: -1 });
|
IncidentSchema.index({ createdAt: -1 });
|
||||||
|
|
||||||
const IncidentModel = model<IncidentDocument>("Incident", IncidentSchema);
|
const IncidentModel = model<IncidentDocument>("Incident", IncidentSchema);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface IIncidentsRepository {
|
|||||||
monitorId?: string,
|
monitorId?: string,
|
||||||
resolutionType?: string
|
resolutionType?: string
|
||||||
): Promise<Incident[]>;
|
): Promise<Incident[]>;
|
||||||
|
findByMonitorIdAndDate(monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise<Incident[]>;
|
||||||
findSummaryByTeamId(teamId: string, limit?: number): Promise<IncidentSummary>;
|
findSummaryByTeamId(teamId: string, limit?: number): Promise<IncidentSummary>;
|
||||||
countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number>;
|
countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number>;
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,17 @@ class MongoIncidentsRepository implements IIncidentsRepository {
|
|||||||
return this.mapDocuments(incidents);
|
return this.mapDocuments(incidents);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
findByMonitorIdAndDate = async (monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise<Incident[]> => {
|
||||||
|
const matchStage = {
|
||||||
|
monitorId: new mongoose.Types.ObjectId(monitorId),
|
||||||
|
teamId: new mongoose.Types.ObjectId(teamId),
|
||||||
|
startTime: { $lt: dateEnd },
|
||||||
|
$or: [{ endTime: null }, { endTime: { $gte: dateStart } }],
|
||||||
|
};
|
||||||
|
const incidents = await IncidentModel.find(matchStage).sort({ startTime: -1 });
|
||||||
|
return this.mapDocuments(incidents);
|
||||||
|
};
|
||||||
|
|
||||||
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>) => {
|
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>) => {
|
||||||
const updatedIncident = await IncidentModel.findOneAndUpdate(
|
const updatedIncident = await IncidentModel.findOneAndUpdate(
|
||||||
{ _id: new mongoose.Types.ObjectId(incidentId), teamId: new mongoose.Types.ObjectId(teamId) },
|
{ _id: new mongoose.Types.ObjectId(incidentId), teamId: new mongoose.Types.ObjectId(teamId) },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const SERVICE_NAME = "incidentService";
|
const SERVICE_NAME = "incidentService";
|
||||||
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { MonitorStatusResponse } from "@/types/network.js";
|
import type { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
import { getDateForRange } from "@/utils/dataUtils.js";
|
||||||
import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
|
import type { IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
|
||||||
@@ -8,10 +8,11 @@ import type { IMonitorsRepository } from "@/domain/monitors/monitor.repository.i
|
|||||||
import type { IUsersRepository } from "@/domain/users/user.repository.interface.js";
|
import type { IUsersRepository } from "@/domain/users/user.repository.interface.js";
|
||||||
import type { Incident, IncidentSummary } from "@/domain/incidents/incident.type.js";
|
import type { Incident, IncidentSummary } from "@/domain/incidents/incident.type.js";
|
||||||
import type { User } from "@/domain/users/user.type.js";
|
import type { User } from "@/domain/users/user.type.js";
|
||||||
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
|
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
|
||||||
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
import { DateRange } from "@/types/query.js";
|
import { DateRange } from "@/types/query.js";
|
||||||
|
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
|
||||||
|
|
||||||
export interface IIncidentService {
|
export interface IIncidentService {
|
||||||
handleIncident(
|
handleIncident(
|
||||||
@@ -106,6 +107,12 @@ export class IncidentService implements IIncidentService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
|
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
|
||||||
|
if (monitor.type === "docker") {
|
||||||
|
const containers = (monitorStatusResponse?.payload as DockerStatusPayload | undefined)?.containers ?? [];
|
||||||
|
const breaches = findContainerBreaches(monitor, containers);
|
||||||
|
return breaches.length > 0 ? breaches.map(describeContainerBreach).join(", ") : "Container alert";
|
||||||
|
}
|
||||||
|
|
||||||
if (!monitorStatusResponse) {
|
if (!monitorStatusResponse) {
|
||||||
return "Threshold breach detected";
|
return "Threshold breach detected";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export interface Incident {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PublicIncident = Pick<
|
||||||
|
Incident,
|
||||||
|
"id" | "monitorId" | "status" | "startTime" | "endTime" | "resolutionType" | "message" | "statusCode" | "createdAt"
|
||||||
|
>;
|
||||||
|
|
||||||
export interface IncidentSummaryTopMonitor {
|
export interface IncidentSummaryTopMonitor {
|
||||||
monitorId: string;
|
monitorId: string;
|
||||||
monitorName: string | null;
|
monitorName: string | null;
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ const JobSchema = new Schema<JobDocument>(
|
|||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Pending checks, inserted but not yet evaluated
|
||||||
|
pendingChecks: {
|
||||||
|
type: [
|
||||||
|
{
|
||||||
|
checkId: { type: String, required: true },
|
||||||
|
createdAt: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: [],
|
||||||
|
_id: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Observability
|
// Observability
|
||||||
runCount: {
|
runCount: {
|
||||||
type: Number,
|
type: Number,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Job, JobSeed, JobType } from "@/domain/jobs/job.type.js";
|
import { Job, JobSeed, JobType, PendingCheck } from "@/domain/jobs/job.type.js";
|
||||||
|
|
||||||
export type JobPageQuery = {
|
export type JobPageQuery = {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -32,9 +32,12 @@ export interface IJobsRepository {
|
|||||||
recordOneShot(id: string, now: number): Promise<boolean>;
|
recordOneShot(id: string, now: number): Promise<boolean>;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
// Hand off to evaluator // This is accomplished by creating an "evaluation" type job
|
// Hand off to evaluator // This is accomplished pushing newly inserted Check IDs onto the evaluation row
|
||||||
// ********************
|
// ********************
|
||||||
upsertEvaluate(monitorId: string, now: number): Promise<boolean>;
|
upsertEvaluate(monitorId: string, pending: PendingCheck[], now: number): Promise<boolean>;
|
||||||
|
|
||||||
|
// Removes processed checks from the evaluate row. Returns false when this worker no longer holds the lease.
|
||||||
|
pullEvaluated(id: string, checkIds: string[]): Promise<boolean>;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
// Job CRUD
|
// Job CRUD
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IJobsRepository, JobPageQuery } from "@/domain/jobs/job.repository.interface.js";
|
import { IJobsRepository, JobPageQuery } from "@/domain/jobs/job.repository.interface.js";
|
||||||
import JobModel, { JobDocument } from "@/domain/jobs/job.model.js";
|
import JobModel, { JobDocument } from "@/domain/jobs/job.model.js";
|
||||||
import { JobType, Job, JobSeed, BACKOFF_MS, LOCK_MS, PARKED, jobId } from "@/domain/jobs/job.type.js";
|
import { JobType, Job, JobSeed, BACKOFF_MS, LOCK_MS, PARKED, jobId, PendingCheck } from "@/domain/jobs/job.type.js";
|
||||||
|
|
||||||
// Cap for rearm interval, never want to wait more than 15s.
|
// Cap for rearm interval, never want to wait more than 15s.
|
||||||
const REARM_JITTER_MAX_MS = 15_000;
|
const REARM_JITTER_MAX_MS = 15_000;
|
||||||
@@ -18,6 +18,7 @@ class MongoJobsRepository implements IJobsRepository {
|
|||||||
intervalMs: doc.intervalMs,
|
intervalMs: doc.intervalMs,
|
||||||
lockedBy: doc.lockedBy,
|
lockedBy: doc.lockedBy,
|
||||||
lockedUntil: doc.lockedUntil,
|
lockedUntil: doc.lockedUntil,
|
||||||
|
pendingChecks: doc.pendingChecks ?? [],
|
||||||
runCount: doc.runCount,
|
runCount: doc.runCount,
|
||||||
failCount: doc.failCount,
|
failCount: doc.failCount,
|
||||||
lastFinishedAt: doc.lastFinishedAt,
|
lastFinishedAt: doc.lastFinishedAt,
|
||||||
@@ -146,24 +147,27 @@ class MongoJobsRepository implements IJobsRepository {
|
|||||||
_id: id,
|
_id: id,
|
||||||
lockedBy: this.workerId, // Only the worker that claimed can record success
|
lockedBy: this.workerId, // Only the worker that claimed can record success
|
||||||
},
|
},
|
||||||
{
|
[
|
||||||
$set: {
|
{
|
||||||
nextScheduledAt: PARKED,
|
$set: {
|
||||||
lockedBy: null, // Remove lock
|
// Re-arm if more checks were pushed while this run held the lease; otherwise park
|
||||||
lockedUntil: null,
|
nextScheduledAt: {
|
||||||
lastFinishedAt: now,
|
$cond: [{ $gt: [{ $size: { $ifNull: ["$pendingChecks", []] } }, 0] }, now, PARKED],
|
||||||
failCount: 0,
|
},
|
||||||
lastFailReason: null,
|
lockedBy: null, // Remove lock
|
||||||
|
lockedUntil: null,
|
||||||
|
lastFinishedAt: now,
|
||||||
|
failCount: 0,
|
||||||
|
lastFailReason: null,
|
||||||
|
runCount: { $add: ["$runCount", 1] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
$inc: {
|
]
|
||||||
runCount: 1, // Increment run count
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return res.modifiedCount === 1;
|
return res.modifiedCount === 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
upsertEvaluate = async (monitorId: string, now: number) => {
|
upsertEvaluate = async (monitorId: string, pending: PendingCheck[], now: number) => {
|
||||||
const filter = { type: "evaluate", refId: monitorId };
|
const filter = { type: "evaluate", refId: monitorId };
|
||||||
const update = {
|
const update = {
|
||||||
$setOnInsert: {
|
$setOnInsert: {
|
||||||
@@ -181,6 +185,9 @@ class MongoJobsRepository implements IJobsRepository {
|
|||||||
$min: {
|
$min: {
|
||||||
nextScheduledAt: now, // no op if there is already a pending evaluation that is older
|
nextScheduledAt: now, // no op if there is already a pending evaluation that is older
|
||||||
},
|
},
|
||||||
|
$addToSet: {
|
||||||
|
pendingChecks: { $each: pending }, // accumulate pending checks; a retried arm never duplicates an entry
|
||||||
|
},
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await JobModel.updateOne(filter, update, { upsert: true });
|
const res = await JobModel.updateOne(filter, update, { upsert: true });
|
||||||
@@ -195,6 +202,18 @@ class MongoJobsRepository implements IJobsRepository {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Returns whether this worker still holds the lease. false means another worker has claimed the row and the caller must stop.
|
||||||
|
pullEvaluated = async (id: string, checkIds: string[]) => {
|
||||||
|
if (checkIds.length === 0) return true;
|
||||||
|
|
||||||
|
const res = await JobModel.updateOne(
|
||||||
|
{ _id: id, lockedBy: this.workerId }, // Only the worker that claimed can pull checks
|
||||||
|
{ $pull: { pendingChecks: { checkId: { $in: checkIds } } } }
|
||||||
|
);
|
||||||
|
return res.matchedCount === 1;
|
||||||
|
};
|
||||||
|
|
||||||
upsertJob = async (job: JobSeed) => {
|
upsertJob = async (job: JobSeed) => {
|
||||||
const res = await JobModel.updateOne(
|
const res = await JobModel.updateOne(
|
||||||
{ _id: job.id },
|
{ _id: job.id },
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export const LOCK_MS = 60_000;
|
|||||||
export const BACKOFF_MS = 5_000;
|
export const BACKOFF_MS = 5_000;
|
||||||
export const PARKED = Number.MAX_SAFE_INTEGER;
|
export const PARKED = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
export type PendingCheck = {
|
||||||
|
checkId: string;
|
||||||
|
createdAt: number; // epoch ms, copied from Check.createdAt
|
||||||
|
};
|
||||||
|
|
||||||
// Canonical _id scheme: monitor-bound rows are `${type}:${refId}`; global rows (refId null: cleanup, egress) are just the type.
|
// Canonical _id scheme: monitor-bound rows are `${type}:${refId}`; global rows (refId null: cleanup, egress) are just the type.
|
||||||
export const jobId = (type: JobType, refId: string | null): string => (refId === null ? type : `${type}:${refId}`);
|
export const jobId = (type: JobType, refId: string | null): string => (refId === null ? type : `${type}:${refId}`);
|
||||||
|
|
||||||
@@ -21,6 +26,9 @@ export type Job = {
|
|||||||
lockedBy: string | null; // workerId holding the lease
|
lockedBy: string | null; // workerId holding the lease
|
||||||
lockedUntil: number | null; // epoch ms; lease expiry => reclaimable after a crash
|
lockedUntil: number | null; // epoch ms; lease expiry => reclaimable after a crash
|
||||||
|
|
||||||
|
// Evaluate these rows only. These are checks that are inserted but not yet evaluated
|
||||||
|
pendingChecks: PendingCheck[];
|
||||||
|
|
||||||
// observability (replaces what getMetrics/getJobs)
|
// observability (replaces what getMetrics/getJobs)
|
||||||
runCount: number;
|
runCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
dockerAlertOnStopped: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
dockerAlertOnUnhealthy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
dockerTlsCa: {
|
dockerTlsCa: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -468,6 +468,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
geoCheckLocations: doc.geoCheckLocations ?? [],
|
geoCheckLocations: doc.geoCheckLocations ?? [],
|
||||||
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
geoCheckInterval: doc.geoCheckInterval ?? 300000,
|
||||||
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
|
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
|
||||||
|
dockerAlertOnStopped: doc.dockerAlertOnStopped ?? false,
|
||||||
|
dockerAlertOnUnhealthy: doc.dockerAlertOnUnhealthy ?? false,
|
||||||
dockerTlsCa: doc.dockerTlsCa ?? undefined,
|
dockerTlsCa: doc.dockerTlsCa ?? undefined,
|
||||||
dockerTlsCert: doc.dockerTlsCert ?? undefined,
|
dockerTlsCert: doc.dockerTlsCert ?? undefined,
|
||||||
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
|
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { IJobScheduler } from "@/worker/worker.interface.js";
|
|||||||
import { DateRange } from "@/types/query.js";
|
import { DateRange } from "@/types/query.js";
|
||||||
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
|
||||||
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
|
||||||
import { isDockerTlsUrl } from "@/utils/dockerHost.js";
|
import { isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "MonitorService";
|
const SERVICE_NAME = "MonitorService";
|
||||||
|
|
||||||
@@ -194,6 +194,9 @@ export class MonitorService implements IMonitorService {
|
|||||||
|
|
||||||
// Encrypt docker TLS keys
|
// Encrypt docker TLS keys
|
||||||
this.encryptDockerTls(body, "create");
|
this.encryptDockerTls(body, "create");
|
||||||
|
if (body.type === "docker" && isCaptureDockerUrl(body.url)) {
|
||||||
|
body.dockerLogsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
const monitor = await this.monitorsRepository.create(body, teamId, userId);
|
const monitor = await this.monitorsRepository.create(body, teamId, userId);
|
||||||
if (!monitor) {
|
if (!monitor) {
|
||||||
@@ -561,6 +564,7 @@ export class MonitorService implements IMonitorService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await this.normalizeCaptureDocker(body, monitorId, teamId);
|
||||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
|
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
|
||||||
await this.scheduler.updateJob(editedMonitor);
|
await this.scheduler.updateJob(editedMonitor);
|
||||||
return editedMonitor;
|
return editedMonitor;
|
||||||
@@ -749,6 +753,26 @@ export class MonitorService implements IMonitorService {
|
|||||||
return { imported: createdMonitors!.length, errors };
|
return { imported: createdMonitors!.length, errors };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture-backed Docker monitors authenticate with the API secret and never collect logs; socket and TLS hosts have no secret.
|
||||||
|
private normalizeCaptureDocker = async (body: Partial<Monitor>, monitorId: string, teamId: string) => {
|
||||||
|
if (body.type !== "docker") return;
|
||||||
|
|
||||||
|
if (body.url !== undefined && !isCaptureDockerUrl(body.url)) {
|
||||||
|
body.secret = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.url === undefined && body.secret === undefined) return;
|
||||||
|
|
||||||
|
const stored = await this.monitorsRepository.findById(monitorId, teamId);
|
||||||
|
if (body.url === undefined && !isCaptureDockerUrl(stored.url)) return;
|
||||||
|
|
||||||
|
body.dockerLogsEnabled = false;
|
||||||
|
const secret = body.secret === undefined ? stored.secret : body.secret.trim();
|
||||||
|
if (!secret) {
|
||||||
|
throw new AppError({ message: "Capture API secret is required", status: 422, service: SERVICE_NAME, method: "editMonitor" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private encryptDockerTls = (body: Partial<Monitor>, mode: "create" | "edit") => {
|
private encryptDockerTls = (body: Partial<Monitor>, mode: "create" | "edit") => {
|
||||||
if (body.type !== "docker" && mode === "create") return;
|
if (body.type !== "docker" && mode === "create") return;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import http from "node:http";
|
|||||||
import { HardwareStats } from "@/domain/checks/check.type.js";
|
import { HardwareStats } from "@/domain/checks/check.type.js";
|
||||||
import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js";
|
import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js";
|
||||||
import { DockerLogPage } from "@/domain/docker/docker-log.type.js";
|
import { DockerLogPage } from "@/domain/docker/docker-log.type.js";
|
||||||
|
import { isDockerSocketUrl } from "@/utils/dockerHost.js";
|
||||||
|
|
||||||
export const HttpStatusCodes = [
|
export const HttpStatusCodes = [
|
||||||
...Object.keys(http.STATUS_CODES).map(Number),
|
...Object.keys(http.STATUS_CODES).map(Number),
|
||||||
@@ -50,6 +51,46 @@ export const UptimeDetailsSupportedTypes = ["http", "ping", "port", "game", "grp
|
|||||||
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
|
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
|
||||||
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
|
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
|
||||||
|
|
||||||
|
const MonitorPaths: Record<MonitorType, string> = {
|
||||||
|
http: "uptime",
|
||||||
|
port: "uptime",
|
||||||
|
ping: "uptime",
|
||||||
|
game: "uptime",
|
||||||
|
grpc: "uptime",
|
||||||
|
websocket: "uptime",
|
||||||
|
dns: "uptime",
|
||||||
|
unknown: "uptime",
|
||||||
|
docker: "docker/host",
|
||||||
|
hardware: "infrastructure",
|
||||||
|
pagespeed: "pagespeed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMonitorPath = (type: MonitorType): string => MonitorPaths[type];
|
||||||
|
|
||||||
|
// Types whose check leaves the instance, and whose failure to reach the target can therefore be the instance's
|
||||||
|
// own loss of egress. A hardware check is an ordinary outbound HTTP request to the Capture agent, so it counts
|
||||||
|
// however far away the agent is. `unknown` is excluded because no request is made for it.
|
||||||
|
export const EgressAttributableTypes = [
|
||||||
|
"http",
|
||||||
|
"ping",
|
||||||
|
"pagespeed",
|
||||||
|
"hardware",
|
||||||
|
"docker",
|
||||||
|
"port",
|
||||||
|
"game",
|
||||||
|
"grpc",
|
||||||
|
"websocket",
|
||||||
|
"dns",
|
||||||
|
] as const satisfies readonly MonitorType[];
|
||||||
|
export type EgressAttributableType = (typeof EgressAttributableTypes)[number];
|
||||||
|
|
||||||
|
// A Docker daemon reached over a unix socket (`unix:///path` or a bare absolute path) is local IPC and cannot
|
||||||
|
// fail through egress; one reached over TCP or TLS is as remote as any other target. The URL, not the type, settles it.
|
||||||
|
const isLocalDockerSocket = (monitor: Pick<Monitor, "type" | "url">): boolean => monitor.type === "docker" && isDockerSocketUrl(monitor.url);
|
||||||
|
|
||||||
|
export const isEgressAttributable = (monitor: Pick<Monitor, "type" | "url">): boolean =>
|
||||||
|
EgressAttributableTypes.some((t) => t === monitor.type) && !isLocalDockerSocket(monitor);
|
||||||
|
|
||||||
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
|
||||||
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||||
|
|
||||||
@@ -109,6 +150,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;
|
||||||
// EncryptionService ciphertext. Normally excluded
|
// EncryptionService ciphertext. Normally excluded
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
import type { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
import type { DockerStatusPayload, HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
|
||||||
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
|
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
|
||||||
import type {
|
import type {
|
||||||
NotificationMessage,
|
NotificationMessage,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ThresholdBreach,
|
ThresholdBreach,
|
||||||
NotificationContent,
|
NotificationContent,
|
||||||
} from "@/domain/notifications/notification.type.js";
|
} from "@/domain/notifications/notification.type.js";
|
||||||
|
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
|
||||||
|
|
||||||
export interface INotificationMessageBuilder {
|
export interface INotificationMessageBuilder {
|
||||||
buildMessage(
|
buildMessage(
|
||||||
@@ -59,32 +60,29 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
return "monitor_down";
|
return "monitor_down";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threshold breach (only if not down)
|
const isDocker = monitor.type === "docker";
|
||||||
if (decision.notificationReason === "threshold_breach") {
|
switch (decision.notificationReason) {
|
||||||
return "threshold_breach";
|
case "threshold_breach":
|
||||||
|
return isDocker ? "container_breach" : "threshold_breach";
|
||||||
|
case "threshold_resolved":
|
||||||
|
return isDocker ? "container_resolved" : "threshold_resolved";
|
||||||
|
default:
|
||||||
|
return "monitor_up";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery from threshold breach (only for hardware monitors)
|
|
||||||
if (decision.notificationReason === "status_change" && monitor.status === "up" && monitor.type === "hardware") {
|
|
||||||
return "threshold_resolved";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard recovery (up)
|
|
||||||
if (monitor.status === "up") {
|
|
||||||
return "monitor_up";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to monitor_up for any other case
|
|
||||||
return "monitor_up";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineSeverity(type: NotificationType): NotificationSeverity {
|
private determineSeverity(type: NotificationType): NotificationSeverity {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "monitor_up":
|
||||||
|
return "success";
|
||||||
case "monitor_down":
|
case "monitor_down":
|
||||||
return "critical";
|
return "critical";
|
||||||
|
case "container_breach":
|
||||||
|
return "warning";
|
||||||
case "threshold_breach":
|
case "threshold_breach":
|
||||||
return "warning";
|
return "warning";
|
||||||
case "monitor_up":
|
case "container_resolved":
|
||||||
|
return "success";
|
||||||
case "threshold_resolved":
|
case "threshold_resolved":
|
||||||
return "success";
|
return "success";
|
||||||
case "test":
|
case "test":
|
||||||
@@ -104,6 +102,10 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>);
|
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>);
|
||||||
case "threshold_resolved":
|
case "threshold_resolved":
|
||||||
return this.buildThresholdResolvedContent(monitor);
|
return this.buildThresholdResolvedContent(monitor);
|
||||||
|
case "container_breach":
|
||||||
|
return this.buildContainerBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<DockerStatusPayload>);
|
||||||
|
case "container_resolved":
|
||||||
|
return this.buildContainersRecoveredContent(monitor);
|
||||||
default:
|
default:
|
||||||
return this.buildDefaultContent(monitor);
|
return this.buildDefaultContent(monitor);
|
||||||
}
|
}
|
||||||
@@ -174,6 +176,23 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildContainerBreachContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse<DockerStatusPayload>): NotificationContent {
|
||||||
|
const payload = monitorStatusResponse.payload;
|
||||||
|
const containers = payload && typeof payload !== "string" ? payload.containers : [];
|
||||||
|
const breaches = findContainerBreaches(monitor, containers);
|
||||||
|
const title = `Container Alert: ${monitor.name}`;
|
||||||
|
const summary = `${breaches.length} container(s) on "${monitor.name}" need attention.`;
|
||||||
|
const details = [`URL: ${monitor.url}`, `Type: ${monitor.type}`, ...breaches.map(describeContainerBreach)];
|
||||||
|
return { title, summary, details, timestamp: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildContainersRecoveredContent(monitor: Monitor): NotificationContent {
|
||||||
|
const title = `Containers Recovered: ${monitor.name}`;
|
||||||
|
const summary = `All containers on "${monitor.name}" are back to normal.`;
|
||||||
|
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
|
||||||
|
return { title, summary, details, timestamp: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
private buildDefaultContent(monitor: Monitor): NotificationContent {
|
private buildDefaultContent(monitor: Monitor): NotificationContent {
|
||||||
return {
|
return {
|
||||||
title: `Monitor: ${monitor.name}`,
|
title: `Monitor: ${monitor.name}`,
|
||||||
@@ -259,7 +278,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
|
|||||||
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
|
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
|
||||||
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
|
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
|
||||||
const threshold = monitor.tempAlertThreshold;
|
const threshold = monitor.tempAlertThreshold;
|
||||||
if (maxTemp >= threshold) {
|
if (maxTemp > threshold) {
|
||||||
breaches.push({
|
breaches.push({
|
||||||
metric: "temp",
|
metric: "temp",
|
||||||
currentValue: maxTemp,
|
currentValue: maxTemp,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { NotificationMessage } from "@/domain/notifications/notification.ty
|
|||||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||||
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
|
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.js";
|
||||||
import { INotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { INotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
|
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
|
||||||
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
|
||||||
@@ -148,7 +148,17 @@ export class NotificationsService implements INotificationsService {
|
|||||||
|
|
||||||
testAllNotifications = async (notificationIds: string[]) => {
|
testAllNotifications = async (notificationIds: string[]) => {
|
||||||
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
|
||||||
const tasks = notifications.map((notification) => this.sendTestNotification(notification));
|
const tasks = notifications.map((notification) =>
|
||||||
|
this.sendTestNotification(notification).catch((error: unknown) => {
|
||||||
|
this.logger.warn({
|
||||||
|
message: `Test notification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
method: "testAllNotifications",
|
||||||
|
details: { notificationId: notification.id, type: notification.type },
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
const outcomes = await Promise.all(tasks);
|
const outcomes = await Promise.all(tasks);
|
||||||
const succeeded = outcomes.filter(Boolean).length;
|
const succeeded = outcomes.filter(Boolean).length;
|
||||||
const failed = outcomes.length - succeeded;
|
const failed = outcomes.length - succeeded;
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export interface AlertDiscordPayload {
|
|||||||
* Part of notification system unification effort
|
* Part of notification system unification effort
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test";
|
export type NotificationType =
|
||||||
|
"monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "container_breach" | "container_resolved" | "test";
|
||||||
|
|
||||||
export type NotificationSeverity = "critical" | "warning" | "info" | "success";
|
export type NotificationSeverity = "critical" | "warning" | "info" | "success";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const SERVICE_NAME = "EmailProvider";
|
const SERVICE_NAME = "EmailProvider";
|
||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { buildTestEmail } from "@/domain/notifications/providers/utils.js";
|
import { buildTestEmail } from "@/domain/notifications/providers/utils.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
import type { ILogger } from "@/utils/logger.js";
|
||||||
@@ -40,11 +41,13 @@ export class EmailProvider extends NotificationProvider {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: "Email test alert failed",
|
message: `Email test alert failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendTestAlert",
|
method: "sendTestAlert",
|
||||||
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
|
if (error instanceof AppError) throw error;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,9 +76,10 @@ export class EmailProvider extends NotificationProvider {
|
|||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: "Email notification failed",
|
message: `Email notification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
method: "sendMessage",
|
method: "sendMessage",
|
||||||
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -92,6 +96,10 @@ export class EmailProvider extends NotificationProvider {
|
|||||||
return `Monitor ${message.monitor.name} threshold exceeded`;
|
return `Monitor ${message.monitor.name} threshold exceeded`;
|
||||||
case "threshold_resolved":
|
case "threshold_resolved":
|
||||||
return `Monitor ${message.monitor.name} thresholds resolved`;
|
return `Monitor ${message.monitor.name} thresholds resolved`;
|
||||||
|
case "container_breach":
|
||||||
|
return `Monitor ${message.monitor.name} container alert`;
|
||||||
|
case "container_resolved":
|
||||||
|
return `Monitor ${message.monitor.name} containers recovered`;
|
||||||
default:
|
default:
|
||||||
return `Alert: ${message.monitor.name}`;
|
return `Alert: ${message.monitor.name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import got from "got";
|
|||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { AlertMatrixPayload, Notification } from "@/domain/notifications/notification.type.js";
|
import type { AlertMatrixPayload, Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export class MatrixProvider extends NotificationProvider {
|
export class MatrixProvider extends NotificationProvider {
|
||||||
@@ -172,10 +172,10 @@ export class MatrixProvider extends NotificationProvider {
|
|||||||
|
|
||||||
// Incident link (if incident exists)
|
// Incident link (if incident exists)
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
const incidentUrl = `${message.clientHost}/infrastructure/${message.monitor.id}`;
|
const url = incidentUrl(message);
|
||||||
plainLines.push("");
|
plainLines.push("");
|
||||||
plainLines.push(`View Incident: ${incidentUrl}`);
|
plainLines.push(`View Incident: ${url}`);
|
||||||
htmlLines.push(`<p><a href="${this.escapeHtml(incidentUrl)}">View Incident</a></p>`);
|
htmlLines.push(`<p><a href="${this.escapeHtml(url)}">View Incident</a></p>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer with timestamp
|
// Footer with timestamp
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "NtfyProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class NtfyProvider extends NotificationProvider {
|
export class NtfyProvider extends NotificationProvider {
|
||||||
@@ -142,7 +142,7 @@ export class NtfyProvider extends NotificationProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`);
|
lines.push("", incidentUrl(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export class PagerDutyProvider extends NotificationProvider {
|
|||||||
|
|
||||||
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
|
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
|
||||||
// Map our notification type to PagerDuty event_action
|
// Map our notification type to PagerDuty event_action
|
||||||
const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger";
|
const eventAction =
|
||||||
|
message.type === "monitor_up" || message.type === "threshold_resolved" || message.type === "container_resolved" ? "resolve" : "trigger";
|
||||||
|
|
||||||
// Map severity to PagerDuty severity levels
|
// Map severity to PagerDuty severity levels
|
||||||
const severityMap: Record<string, string> = {
|
const severityMap: Record<string, string> = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const SERVICE_NAME = "PushoverProvider";
|
|||||||
import type { Notification } from "@/domain/notifications/notification.type.js";
|
import type { Notification } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class PushoverProvider extends NotificationProvider {
|
export class PushoverProvider extends NotificationProvider {
|
||||||
@@ -101,7 +101,7 @@ export class PushoverProvider extends NotificationProvider {
|
|||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
|
lines.push(`View Incident: ${incidentUrl(message)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const SERVICE_NAME = "RocketChatProvider";
|
const SERVICE_NAME = "RocketChatProvider";
|
||||||
import { supportsUptimeDetails, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
import { getMonitorPath, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||||
import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js";
|
import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
||||||
@@ -139,19 +139,7 @@ export class RocketChatProvider extends NotificationProvider {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.monitor.type === "hardware") {
|
return `${clientHost}/${getMonitorPath(message.monitor.type as MonitorType)}/${message.monitor.id}`;
|
||||||
return `${clientHost}/infrastructure/${message.monitor.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.monitor.type === "pagespeed") {
|
|
||||||
return `${clientHost}/pagespeed/${message.monitor.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportsUptimeDetails(message.monitor.type as MonitorType)) {
|
|
||||||
return `${clientHost}/uptime/${message.monitor.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private severityColor(severity: NotificationMessage["severity"]): string {
|
private severityColor(severity: NotificationMessage["severity"]): string {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const SERVICE_NAME = "SignalgridProvider";
|
const SERVICE_NAME = "SignalgridProvider";
|
||||||
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
|
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
|
||||||
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
|
||||||
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
|
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
|
||||||
import got from "got";
|
import got from "got";
|
||||||
|
|
||||||
export class SignalgridProvider extends NotificationProvider {
|
export class SignalgridProvider extends NotificationProvider {
|
||||||
@@ -115,7 +115,7 @@ export class SignalgridProvider extends NotificationProvider {
|
|||||||
|
|
||||||
if (message.content.incident) {
|
if (message.content.incident) {
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
|
lines.push(`View Incident: ${incidentUrl(message)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user