Compare commits

...
Author SHA1 Message Date
Alex Holliday 4a66cf6e11 improve email error handling 2026-09-23 12:13:27 -07:00
Alexander Holliday 41c7cfbdaa Merge pull request #3986 from bluewave-labs/feat/docker-container-alerts
feat: docker container alerts
2026-09-23 11:50:04 -07:00
Alex Holliday 7fb57d8090 fix incident links with helper 2026-09-23 11:46:58 -07:00
Alex Holliday 618258d282 fix docker path 2026-09-23 11:39:16 -07:00
Alex Holliday e50568dba3 add summary props to docker monitor list page, add localisation strings 2026-09-23 11:31:37 -07:00
Alex Holliday 5fa894cb79 Add client side types, validation, add swithces to monitor form 2026-09-23 11:17:19 -07:00
Alex Holliday 6d4e944a58 Add tests 2026-09-23 11:17:01 -07:00
Alex Holliday cfe7d2a359 add missing braces, fix type error 2026-09-23 11:06:25 -07:00
Alex Holliday 5fb26367ed reorder docker case 2026-09-23 11:03:33 -07:00
Alex Holliday cd65488cbd add docker branch to incident service, update providers 2026-09-23 10:57:49 -07:00
Alex Holliday 4ea6539a7d build notifications for container breaches 2026-09-23 10:54:35 -07:00
Alex Holliday a75bb5fae6 add docker container alert fields to monitors,checks 2026-09-23 10:16:49 -07:00
Alexander Holliday a5e66318b5 Merge pull request #3975 from nikhilsaxena04/feat/status-page-incident-history
feat: add clickthrough incident history to public status page (#3972)
2026-09-22 10:15:57 -07:00
nikhil2004 7ebbb05482 i18n: add errorLoading translation key to all locales 2026-09-22 22:35:15 +05:30
nikhil2004 99c62961cf feat: add keepPreviousData option to useGet in IncidentModal 2026-09-22 22:29:00 +05:30
nikhil2004 40a9f3f1e1 refactor: use Zod date native validation for query date 2026-09-22 22:27:16 +05:30
nikhil2004 b37e4d9083 refactor: replace hardcoded color with theme palette reference 2026-09-22 22:20:03 +05:30
nikhil2004 37dd9fad26 refactor: use global Incident type instead of local interface 2026-09-22 22:18:47 +05:30
nikhil2004 0f51d68ff0 chore: add missing translation key for errorLoading and remove fallback 2026-09-22 22:16:52 +05:30
Alexander Holliday dfc048522d Merge pull request #3982 from bluewave-labs/fix/dashboard-types
fix: Dashboard types
2026-09-22 09:43:58 -07:00
Alex Holliday 1f9072a905 add missing imports 2026-09-22 09:42:15 -07:00
nikhil2004 780ed8147b fix: resolve CORS issue on public status pages and handle UI errors 2026-09-22 22:06:38 +05:30
Alex Holliday b2cfc60387 Move types to dir, div -> Fragment 2026-09-22 09:35:53 -07:00
Alexander Holliday c8f84e9e20 Merge pull request #3976 from bluewave-labs/feat/dashboard-edit-visible-cards
feat(dashboard): add edit visible cards modal
2026-09-22 09:30:13 -07:00
nikhil2004 9434cd8929 fix: allow owners to view incidents on unpublished status pages 2026-09-22 21:57:54 +05:30
nikhil2004 55184724d0 fix: resolve failing CI checks 2026-09-22 04:42:32 +05:30
nikhil2004 de4c528d55 fix: address PR feedback 2026-09-22 03:33:27 +05:30
nikhil2004 d5e887903e chore: fix formatting for IncidentModal.tsx and openapi.json 2026-09-22 03:31:15 +05:30
nikhil2004 6196b81fe4 fix: resolve client CI build and Prettier formatting errors 2026-09-22 03:31:15 +05:30
nikhil2004 c3598a67fa fix(client): resolve linting warnings in IncidentModal 2026-09-22 03:31:15 +05:30
nikhil2004 2c896a5506 fix(client): resolve TS2769 error in ThemedHeatmap and ThemedHistogram 2026-09-22 03:31:14 +05:30
nikhil2004 97e23e4b3a fix(client): allow clicking empty cells to view historical incidents 2026-09-22 03:31:14 +05:30
nikhil2004 be39ad0bfe fix: add missing dayjs dependency 2026-09-22 03:31:14 +05:30
nikhil2004 7f0e577dd2 feat: clickthrough historical data view for status page downtime (#3972)
This adds the ability for users to click on any day block in the historical uptime chart to see the specific incidents that occurred on that day.

Closes #3972
2026-09-22 03:31:14 +05:30
Alexander Holliday f308325bd6 Merge pull request #3960 from bluewave-labs/feat/scrollbar-style
feat: scrollbar styling
2026-09-21 10:31:42 -07:00
Alexander Holliday c5303add56 Merge pull request #3981 from bluewave-labs/fix/docker-capture-monitors
docker capture fixes
2026-09-21 10:30:16 -07:00
Alex Holliday fe6f755451 add readme 2026-09-21 10:26:53 -07:00
Alex Holliday 4a2429d316 docker capture fixes 2026-09-21 10:22:46 -07:00
Alexander Holliday d7f8bcc9e1 Merge pull request #3973 from fabiangigler/feat/capture-docker-monitors
Support Capture-backed Docker monitors
2026-09-21 10:05:51 -07:00
Alexander Holliday d51fdbd772 Merge pull request #3978 from bluewave-labs/chore/readability-refactor
chore: refactor worker services into a pipeline for readability
2026-09-21 09:38:56 -07:00
Alexander Holliday 199d68e8a2 Merge pull request #3974 from nikhilsaxena04/fix/grpc-monitor-proto-path
fix(network): correctly resolve health.proto path for gRPC monitors and include in build
2026-09-21 09:17:36 -07:00
Alex Holliday 461a5e35dd format openapi 2026-09-21 09:13:45 -07:00
Alex Holliday 8dd57d45e2 port egress to pipeline 2026-09-20 17:37:42 -07:00
Alexander Holliday 01e9655f42 Merge pull request #3977 from bluewave-labs/fix/docker-socket-egress-exemption
fix: exempt local docker sockets
2026-09-20 16:53:21 -07:00
Alex Holliday d4d2735d23 exempt local docker sockets 2026-09-20 16:37:02 -07:00
Alexander Holliday 253642dde9 Merge pull request #3963 from web-engineer/feat/egress-2-check-persistence
Egress self-check (2/4): flag checks taken during degraded egress (#3937)
2026-09-20 16:31:06 -07:00
fabiangigler 2f3de9797d Use network types for Capture responses 2026-09-19 22:51:23 +02:00
Craig Lawson 1b3b8e5312 Clear the status window when a degraded spell starts
The window was carried across a spell, so the results either side of it ended up
adjacent: a monitor at [t, t, t, f, f] when egress dropped resumed at
[t, t, f, f, f] on its first real failure afterwards, crossing the threshold on
results taken before the outage.

Cleared in the producer beside the maintenance gate, which already clears the
window on entering a window, and guarded on its length so it is one write per
spell rather than one per degraded check. The monitor still goes down once the
rebuilt window holds enough real failures.

The two integration tests that asserted the old behaviour are rewritten: one
expected the window preserved through a spell, the other expected failures
either side of a spell to combine.

🔧
2026-09-19 14:43:16 +01:00
Craig Lawson b55560e2f1 Report whether the peer answered from every provider that can tell
An egress spell masked a service on the surviving network going down: with
`peerResponded` set only by the DNS and gRPC providers, every other failure fell
back to the HTTP-status heuristic, which answers "nothing replied" for anything
that is not speaking HTTP. A refused connection on the local network was read as
the instance's own loss of egress, so the check was recorded as degraded and the
monitor stayed up.

`peerAnsweredFromError` holds the errno codes that require the target's own stack
to have replied — a refusal, a reset, a TLS handshake that got as far as a
certificate. Http, Port, WebSocket and Docker use it; Http also treats any status
code as an answer, and Docker any status from the daemon. Hardware and PageSpeed
go through Http and inherit it.

`true` is the claim that costs something, since it blames the target, so it is
only set on evidence. Two cases stay conservative and are commented as such: a
ping that draws no echo reply, and a gamedig query that fails, neither of which
can be told apart from the instance losing its egress. Nor can a proxied HTTP
request that fails with no response, where the refusal may be the proxy's.

🔧
2026-09-19 14:43:11 +01:00
nikhil2004 57ddbc474f fix(network): correctly resolve health.proto path for gRPC monitors and include in build
Fixes #3970 by pointing protoPath to the correct relative directory (../protos) and adding a step in the package.json build script to copy the protos directory to the final dist output.
2026-09-19 01:03:42 +05:30
fabiangigler 862ff15eef Support Capture-backed Docker monitors 2026-09-18 19:54:33 +02:00
Craig Lawson 13799af721 Read unevaluated checks by pending entry in the exclusion test
findUnevaluatedByMonitorId now takes the job's pendingChecks rather than a
timestamp, so the test builds those entries from the ids createChecks returns.

🔧
2026-09-17 22:08:14 +01:00
Craig Lawson 4fcb13694b Exclude degraded checks from the docker and hardware aggregates
Those aggregations were reverted when docker and hardware could not carry the
flag. Both types are now egress attributable, so their checks can be degraded
and their totals, bucket counts and latest-check reads must exclude them like
every other aggregate.

🔧
2026-09-17 22:06:34 +01:00
Craig Lawson 3dc529733a Address the inline review on the egress check pipeline
Distinguish "the peer answered" from "nothing answered", widen attribution to
every check that leaves the instance, make the maintenance exit explicit, and
fix the recovery poll cadence.

Providers can now say outright whether the peer answered, via peerResponded on
MonitorStatusResponse. Previously the producer inferred it from an HTTP status
code, which two providers do not report:

- GrpcProvider: NOT_SERVING shares NETWORK_ERROR with a connection failure, and
  gRPC codes 0-16 are not HTTP statuses, so PERMISSION_DENIED, UNIMPLEMENTED
  and the rest were all read as transport failures. Only DEADLINE_EXCEEDED and
  UNAVAILABLE, or a failure carrying no gRPC code, now count as unreachable.
- DNSProvider: every resolver error became NETWORK_ERROR, so an authoritative
  NXDOMAIN, SERVFAIL or REFUSED was read as unreachable. The c-ares codes where
  the server replied are now separated from timeout and connection refused.

Left unset, the HTTP-status heuristic still applies, so other providers are
unchanged.

Attribution now follows whether the check leaves the instance, not the monitor
type. A hardware check is an ordinary outbound HTTP request to the Capture
agent, and a Docker daemon can be reached over TCP, so both are attributable;
only a Docker daemon on a unix socket is local and exempt.

Leaving a maintenance window is now explicit in the producer, alongside the
entry it already owned. It was a side effect of evaluating a check, which the
degraded-egress short-circuit skips, so a monitor whose window ended during an
outage kept reporting "maintenance" for the length of it.

The recovery poll interval is fixed at 5 seconds and no longer configurable.
While degraded, checks are flagged from stored state without probing, so the
interval is the window in which a recovered instance still discards its checks;
the only effect of raising it was a longer blind window. egressPollIntervalSeconds
is removed from the settings, the validator and the OpenAPI spec.

🔧
2026-09-17 22:06:34 +01:00
Craig Lawson d865103fb5 Exclude degraded-egress checks from every aggregate
A degraded episode now reads as a gap in monitoring rather than as downtime
or as a dip in response time. The exclusion is applied once per query, in the
top-level $match, so no pipeline can count a degraded check by omission.

- check.query.ts: EXCLUDE_DEGRADED_EGRESS_MATCH is the only helper;
  IS_NOT_DEGRADED_EGRESS_EXPR and the $cond accumulators it fed are gone.
- findUptimeDateRangeChecks: excludes at the match, so the response-time
  series, the up/down series and the averages drop degraded checks too.
- findPageSpeedDateRangeChecks: excludes as well; pagespeed is egress
  attributable and was previously missed.
- getDailyStatusBuckets: excludes at the match; the group shapes and the
  totalChecks > 0 guard revert to their original form.
- check.docker.aggregation.ts, check.hardware.aggregations.ts: reverted.
  Neither type is egress attributable, so their checks never carry the flag
  and the filters were dead code. A test now pins that decision.
- Tests: the gap behaviour, a window that was entirely degraded, pagespeed,
  the team listing and the resolve filter, and an incident that stays open
  when egress drops during it.

🔧
2026-09-17 22:06:16 +01:00
Craig Lawson 4335e6b7b6 Address review of degraded-egress check persistence
CheckProducer consults egress only for a transport failure: the check
failed, no HTTP status came back (isHttpStatusCode from PR 1), and the
monitor type is one whose failure can be a loss of egress
(EgressAttributableTypes; hardware and docker are excluded). A 4xx/5xx
or a content mismatch is evaluated as usual while the instance is
degraded. A rejected assessment is logged and treated as unknown so the
check is still buffered.

EXCLUDE_DEGRADED_EGRESS_MATCH moves out of the model into
check.query.ts, with an expression form for $group accumulators.
Daily status buckets and docker stats buckets exclude degraded checks
from their up/total counts while keeping them in the response-time
average, matching the uptime facet. toEntity maps egressStatus like its
siblings.

Adds heartbeatJobDegradedEgress integration tests, extends the
repository exclusion tests, and makes the harness egress stub settable.

🔧
2026-09-17 22:06:16 +01:00
Craig Lawson 4f5dba560d Add integration test for degraded egress uptime exclusions
Runs the summary, daily bucket, date-range, hardware and docker aggregations
against a live mongod with a mix of degraded and ordinary checks, and covers
the egressStatus round trip through createChecks and findUnevaluatedByMonitorId.

🔧
2026-09-17 22:06:16 +01:00
Craig Lawson f52acb3f68 Flag checks taken during degraded egress and exclude them from uptime (#3937)
CheckProducer asks EgressService to assess the instance's own connectivity
after a failing check and stamps the result on the check as egressStatus.
CheckEvaluator short-circuits degraded checks so no incident opens and no
spurious recovery fires later. Uptime and down-count aggregations exclude
degraded checks; the response-time series keeps them.

🔧
2026-09-17 22:06:16 +01:00
Alex Holliday 2632f98ad0 Add scrollbar with thumb and tack options to the palette. Add scrollbar styles to MUI theme 2026-09-14 10:12:18 -07:00
165 changed files with 4603 additions and 1420 deletions

No files matched your search

+5 -2
View File
@@ -134,14 +134,17 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
### 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 |
|---|---|---|
| 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. |
| 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:
+2 -22
View File
@@ -93,7 +93,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -409,7 +408,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +445,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1236,7 +1233,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1341,7 +1337,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2386,7 +2381,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2402,7 +2396,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2493,7 +2486,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2774,7 +2766,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3131,7 +3122,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3565,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3975,7 +3964,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4776,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5600,7 +5587,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6382,7 +6368,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6394,7 +6379,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6598,8 +6582,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6749,7 +6732,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7591,7 +7573,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7729,7 +7710,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+1 -3
View File
@@ -1,11 +1,9 @@
import { dashboardCardKeys, type DashboardCardKey } from "@/Types/Dashboard";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
type TableName =
| "monitors"
| "team"
+3
View File
@@ -77,6 +77,7 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
secret: data?.secret || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
@@ -84,6 +85,8 @@ export const getMonitorDefaults = (
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
dockerAlertOnStopped: data?.dockerAlertOnStopped ?? false,
dockerAlertOnUnhealthy: data?.dockerAlertOnUnhealthy ?? false,
};
break;
case "game":
+40 -15
View File
@@ -51,6 +51,7 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
isCaptureDockerUrl,
isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
@@ -350,6 +351,7 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType;
const watchedUrl = watch("url") as string;
const isCaptureDocker = watchedType === "docker" && isCaptureDockerUrl(watchedUrl);
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
@@ -380,6 +382,13 @@ const CreateMonitorPage = () => {
}
}, [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(
() => getGeneralSettingsConfig(watchedType, t),
[watchedType, t]
@@ -608,7 +617,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.createMonitor.form.general.title")}
@@ -709,8 +717,8 @@ const CreateMonitorPage = () => {
/>
)}
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
{/* Capture API secret - used by hardware and Capture-backed Docker monitors */}
{(generalSettingsConfig.showSecret || isCaptureDocker) && (
<FormTextField
name="secret"
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 && (
<FormTextField
name="dnsServer"
@@ -743,7 +757,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
@@ -801,7 +814,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
@@ -822,7 +834,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{/* Alert Thresholds - only for hardware type */}
{watchedType === "hardware" && showStep(1) && (
<ConfigBox
@@ -878,7 +889,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.incidents.title")}
@@ -905,7 +915,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.notifications.title")}
@@ -918,7 +927,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.tags.title")}
@@ -945,8 +953,7 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(1) && watchedType === "docker" && (
{showStep(1) && watchedType === "docker" && !isCaptureDocker && (
<ConfigBox
title={t("pages.createMonitor.form.dockerLogs.title")}
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) &&
(watchedType === "http" ||
watchedType === "grpc" ||
@@ -974,7 +1002,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(2) && watchedType === "http" && (
<ConfigBox
title={t("pages.createMonitor.form.advanced.title")}
@@ -1074,7 +1101,6 @@ const CreateMonitorPage = () => {
}
/>
)}
{showStep(2) && supportsGeoCheck(watchedType) && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
@@ -1107,7 +1133,6 @@ const CreateMonitorPage = () => {
}
/>
)}
<Stack
direction="row"
justifyContent={!isEditMode ? "space-between" : "flex-end"}
@@ -3,9 +3,7 @@ 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 "@/Features/UI/uiSlice";
export type { DashboardCardKey };
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
interface EditCardsModalProps {
open: boolean;
@@ -52,7 +50,7 @@ export const EditCardsModal = ({
fullWidth
>
<Stack>
{dashboardCardKeys.map((key) => (
{dashboardCardKeys.map((key: DashboardCardKey) => (
<FormControlLabel
key={key}
label={t(`pages.dashboard.cards.${key}`)}
+4 -7
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from "react";
import { Fragment, useMemo, useState, type ReactNode } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
@@ -10,16 +10,13 @@ import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
import {
setDashboardVisibleCards,
dashboardCardKeys,
type DashboardCardKey,
} from "@/Features/UI/uiSlice";
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
import type { RootState, AppDispatch } from "@/store";
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
const Dashboard = () => {
const theme = useTheme();
@@ -78,7 +75,7 @@ const Dashboard = () => {
{dashboardCardKeys
.filter((key) => visibleCards.has(key))
.map((key) => (
<div key={key}>{cardComponents[key]}</div>
<Fragment key={key}>{cardComponents[key]}</Fragment>
))}
</Box>
</Stack>
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatDuration } from "@/Utils/TimeUtils";
import { PLACEHOLDER } from "@/Utils/FormatUtils";
import { getDockerPalette } from "@/Utils/MonitorUtils";
export const DockerContainerStatusBoxes = ({
@@ -23,8 +24,9 @@ export const DockerContainerStatusBoxes = ({
if (!container) return null;
const { state, restartCount, health } = container;
const startedAt = container.startedAt ?? "0";
const runningFor = Date.now() - new Date(startedAt).getTime();
const runningFor = container.startedAt
? Date.now() - new Date(container.startedAt).getTime()
: undefined;
const palette = getDockerPalette(state);
return (
<Stack
@@ -39,11 +41,11 @@ export const DockerContainerStatusBoxes = ({
/>
<StatBox
title={t("common.labels.uptime")}
subtitle={formatDuration(runningFor)}
subtitle={runningFor === undefined ? PLACEHOLDER : formatDuration(runningFor)}
/>
<StatBox
title={t("common.labels.restarts")}
subtitle={String(restartCount ?? 0)}
subtitle={restartCount === undefined ? PLACEHOLDER : String(restartCount)}
/>
<StatBox
title={t("common.labels.health")}
@@ -13,7 +13,7 @@ import type { Theme } from "@mui/material";
import type { TFunction } from "i18next";
import type { DockerContainerStats } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatPercentage } from "@/Utils/FormatUtils";
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
import prettyBytes from "pretty-bytes";
import { BaseBox } from "@/Components/design-elements";
import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils";
@@ -88,11 +88,17 @@ export const TabOverview = ({
Ports
</Typography>
<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 (
<Typography
key={`${port.publicPort}/${port.privatePort}`}
>{`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`}</Typography>
<Typography key={`${port.hostIp}/${port.publicPort}/${containerPort}`}>
{label}
</Typography>
);
})}
</Stack>
@@ -111,6 +117,7 @@ export const TabOverview = ({
Volumes
</Typography>
<Stack>
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
@@ -12,7 +12,7 @@ import type { Header } from "@/Components/design-elements/Table";
import type { DockerContainerInfo } from "@/Types/Check";
// Utils
import { formatPercentage } from "@/Utils/FormatUtils";
import { formatPercentage, PLACEHOLDER } from "@/Utils/FormatUtils";
interface DockerContainersTableProps {
monitorId: string | undefined;
@@ -91,7 +91,7 @@ export const DockerContainersTable = ({
id: "restarts",
content: t("common.labels.restarts"),
render: (row) => {
return <Typography>{row.restartCount}</Typography>;
return <Typography>{row.restartCount ?? PLACEHOLDER}</Typography>;
},
},
];
@@ -19,6 +19,7 @@ const DockerMonitorsPage = () => {
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<DockerMonitorsTable
monitors={c.monitors || []}
@@ -1,7 +1,7 @@
import Box from "@mui/material/Box";
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 { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
@@ -11,7 +11,6 @@ interface StatGaugesProps {
diagnostics: Diagnostics | null;
}
const PLACEHOLDER = "—";
export const StatGauges = ({ diagnostics }: StatGaugesProps) => {
const theme = useTheme();
+1 -1
View File
@@ -6,12 +6,12 @@ import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics";
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
import { PLACEHOLDER } from "@/Utils/FormatUtils";
interface StatsProps {
diagnostics: Diagnostics | null;
}
const PLACEHOLDER = "—";
export const Stats = ({ diagnostics }: StatsProps) => {
const { t } = useTranslation();
@@ -14,6 +14,7 @@ import {
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal";
import {
ThemedInfrastructure,
type GaugeFillLevel,
@@ -116,6 +117,8 @@ export const BaseStatusPage = ({
[config, tokens, mode]
);
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 logoSrc = statusPage.logo?.data
@@ -306,6 +309,10 @@ export const BaseStatusPage = ({
cells={cells}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/>
) : (
<ThemedHistogram
@@ -313,6 +320,10 @@ export const BaseStatusPage = ({
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/>
))}
</Box>
@@ -333,6 +344,16 @@ export const BaseStatusPage = ({
Checkmate
</a>
</Box>
<IncidentModal
url={statusPage.url}
monitorId={selectedMonitorId}
date={selectedDate}
timezone={bucketTimezone}
onClose={() => {
setSelectedMonitorId(null);
setSelectedDate(null);
}}
/>
</Box>
);
};
@@ -25,6 +25,7 @@ export interface ChartCell {
responseTime: number; // 0 for empty cells
tooltip: React.ReactNode | null;
ariaLabel: string;
date: string | null;
}
const DAY_DOWN_THRESHOLD = 0.5;
@@ -61,7 +62,7 @@ export const classifyResponseTime = (
return "fast";
};
const emptyCell = (key: string): ChartCell => ({
const emptyCell = (key: string, date: string | null = null): ChartCell => ({
key,
barKind: "empty",
heatKind: "empty",
@@ -70,6 +71,7 @@ const emptyCell = (key: string): ChartCell => ({
tooltip: null,
ariaLabel: "",
severity: 0,
date,
});
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
@@ -92,6 +94,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
tooltip: <ThemedChartTooltip check={check} />,
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
severity: 0,
date: null,
})
);
return padCells(cells, MAX_RECENT_CHECKS);
@@ -108,7 +111,7 @@ export const dailyBucketsToCells = (
const enumerated = enumerateDays(days, bucketTimezone);
return enumerated.map((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 barKind = classifyDay(bucket, upRatio);
@@ -132,6 +135,7 @@ export const dailyBucketsToCells = (
date,
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 type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
import type {
ChartCell,
HeatCellKind,
@@ -11,17 +13,15 @@ interface Props {
cells: ChartCell[];
containerSx: 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();
return (
<Box
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
>
@@ -30,7 +30,15 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
return (
<Box
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"
>
<Box
sx={cellSx(cell.heatKind, cell.severity)}
sx={mergeSx(
cellSx(cell.heatKind, cell.severity),
!!cell.date && !!onCellClick ? { cursor: "pointer" } : undefined
)}
aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/>
</Tooltip>
);
@@ -5,6 +5,8 @@ import Stack from "@mui/material/Stack";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { formatMs } from "@/Utils/TimeUtils";
import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx";
import type {
BarKind,
ChartCell,
@@ -16,6 +18,7 @@ interface Props {
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
onCellClick?: (date: string) => void;
}
export const ThemedHistogram = ({
@@ -24,6 +27,7 @@ export const ThemedHistogram = ({
barSx,
statsSx,
statsGap = 1,
onCellClick,
}: Props) => {
const { t } = useTranslation();
@@ -39,17 +43,22 @@ export const ThemedHistogram = ({
return (
<Stack gap={statsGap}>
<Box
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
sx={mergeSx(containerSx, { gridTemplateColumns: `repeat(${cells.length}, 1fr)` })}
>
{cells.map((cell) => {
if (cell.barKind === "empty") {
return (
<Box
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"
>
<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}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/>
</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>;
};
+2
View File
@@ -0,0 +1,2 @@
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
+2
View File
@@ -169,6 +169,8 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerAlertOnStopped?: boolean;
dockerAlertOnUnhealthy?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
+3
View File
@@ -47,3 +47,6 @@ export const getPercentage = (value: number, total: number) => {
if (!value || !total) return 0;
return (value / total) * 100;
};
// Shown wherever a value is not available for the current data source.
export const PLACEHOLDER = "—";
+1 -1
View File
@@ -20,7 +20,7 @@ export const getMonitorPath = (type: MonitorType): string => {
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
docker: "docker",
docker: "docker/host",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
+8
View File
@@ -85,6 +85,10 @@ export const lightPalette = {
sidebar: {
accent: colors.brandGreen,
},
scrollbar: {
track: colors.rule200,
thumb: colors.brandGreen,
},
rowStatus: {
running: colors.green200,
paused: colors.yellow200,
@@ -137,6 +141,10 @@ export const darkPalette = {
sidebar: {
accent: colors.brandGreenLight,
},
scrollbar: {
track: colors.ruleDark,
thumb: colors.brandGreen,
},
rowStatus: {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
+24
View File
@@ -3,6 +3,7 @@ import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palet
import type { Theme } from "@mui/material/styles";
import type {} from "@mui/material/styles";
import { SCROLLBAR_SIZE } from "@/Utils/Theme/constants";
declare module "@mui/material/styles" {
interface TypographyVariants {
@@ -20,6 +21,7 @@ declare module "@mui/material/styles" {
}
interface Palette {
sidebar: { accent: string };
scrollbar: { track: string; thumb: string };
rowStatus: { running: string; paused: string };
chart: {
phases: {
@@ -34,6 +36,7 @@ declare module "@mui/material/styles" {
}
interface PaletteOptions {
sidebar?: { accent: string };
scrollbar?: { track: string; thumb: string };
rowStatus?: { running: string; paused: string };
chart?: {
phases: {
@@ -112,6 +115,27 @@ export const theme = (mode: string, palette: any) =>
},
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: {
styleOverrides: {
root: {
+1 -1
View File
@@ -19,7 +19,7 @@ export const LAYOUT = {
} as const;
export const INPUT_BASE_HEIGHT = 32; // Height for inputs/controls
export const SCROLLBAR_SIZE = 16;
export const HOVER = {
DARKEN: 0.06, // This is a coefficient for darkening function
ROW: 0.025, // Overlay alpha for hoverable rows and cards
+31 -4
View File
@@ -110,23 +110,40 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema. The url forms mirror DOCKER_TLS_URL and
// DOCKER_SOCKET_URL in server/src/utils/dockerHost.ts.
// Docker monitor schema. The URL forms mirror the Docker source helpers in
// server/src/utils/dockerHost.ts.
const dockerTlsUrlRegex = /^(tcp|https):\/\/([^/\s:]+)(?::(\d+))?\/?$/;
const dockerSocketUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
export const isDockerTlsUrl = (url: string | undefined): boolean =>
dockerTlsUrlRegex.test(url?.trim() ?? "");
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({
type: z.literal("docker"),
url: z
.string()
.min(1, "Docker host URL is required")
.refine(
(url) => dockerSocketUrlRegex.test(url.trim()) || isDockerTlsUrl(url),
"Docker host must be unix:///path, an absolute socket path, or tcp://host[:port]"
(url) =>
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(),
dockerTlsCa: z.string(),
dockerTlsCert: z.string(),
@@ -135,6 +152,8 @@ const dockerSchema = baseSchema.extend({
// Read only mirror of the server flag, stripped on submit
dockerTlsKeySet: z.boolean(),
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
@@ -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.dockerTlsCert.trim()) {
ctx.addIssue({
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "صيانة",
"paused": "متوقف مؤقتاً",
"up": "قيد التشغيل"
},
"incidents": {
"errorLoading": "فشل تحميل الحوادث."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"uptime": {
"title": "Temps en línia",
"responseTime": "Temps de resposta"
},
"incidents": {
"errorLoading": "No s'han pogut carregar les incidències."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Údržba",
"paused": "Pozastaveno",
"up": "V provozu"
},
"incidents": {
"errorLoading": "Nepodařilo se načíst incidenty."
}
},
"statusBar": {
+3
View File
@@ -1378,6 +1378,9 @@
"maintenance": "Wartung",
"paused": "Pausiert",
"up": "Betriebsbereit"
},
"incidents": {
"errorLoading": "Vorfälle konnten nicht geladen werden."
}
},
"statusBar": {
+25 -6
View File
@@ -557,7 +557,7 @@
},
"status": {
"down": "down",
"breached": "threshold exceeded",
"breached": "alert",
"initializing": "initializing",
"maintenance": "maintenance",
"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.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker 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.",
"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.",
@@ -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": {
"title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
@@ -1685,7 +1697,7 @@
"status": {
"up": "Operational",
"down": "Down",
"breached": "Threshold exceeded",
"breached": "Alert",
"maintenance": "Maintenance",
"paused": "Paused",
"initializing": "Initializing"
@@ -1712,19 +1724,26 @@
"uptime": {
"title": "Uptime",
"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": {
"monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
"monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
"allUp": "All systems operational",
"allBreached": "All systems have exceeded thresholds",
"allBreached": "All systems have active alerts",
"allMaintenance": "All systems are under maintenance",
"allDown": "All systems are down",
"allPaused": "All systems are paused",
"allInitializing": "All systems are initializing",
"breachedAndDown": "Some systems have exceeded thresholds and are down",
"breached": "Some systems have exceeded thresholds",
"breachedAndDown": "Some systems have active alerts and some are down",
"breached": "Some systems have active alerts",
"degraded": "Some systems are experiencing issues",
"maintenanceAndDown": "Some systems are down during maintenance",
"maintenance": "Some systems are under maintenance",
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Mantenimiento",
"paused": "Pausado",
"up": "Operativo"
},
"incidents": {
"errorLoading": "Error al cargar incidentes."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Huolto",
"paused": "Keskeytetty",
"up": "Toiminnassa"
},
"incidents": {
"errorLoading": "Häiriöiden lataaminen epäonnistui."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Maintenance",
"paused": "En pause",
"up": "Opérationnel"
},
"incidents": {
"errorLoading": "Échec du chargement des incidents."
}
},
"statusBar": {
+3
View File
@@ -1473,6 +1473,9 @@
"uptime": {
"title": "Uptime",
"responseTime": "Tempo di risposta"
},
"incidents": {
"errorLoading": "Impossibile caricare gli incidenti."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "メンテナンス",
"paused": "一時停止中",
"up": "稼働中"
},
"incidents": {
"errorLoading": "インシデントの読み込みに失敗しました。"
}
},
"statusBar": {
+3
View File
@@ -1584,6 +1584,9 @@
"uptime": {
"responseTime": "Czas odpowiedzi",
"title": "Czas pracy"
},
"incidents": {
"errorLoading": "Nie udało się załadować incydentów."
}
},
"preview": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Manutenção",
"paused": "Pausado",
"up": "Operacional"
},
"incidents": {
"errorLoading": "Falha ao carregar incidentes."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Обслуживание",
"paused": "Приостановлено",
"up": "Работает"
},
"incidents": {
"errorLoading": "Не удалось загрузить инциденты."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "การบำรุงรักษา",
"paused": "หยุดชั่วคราว",
"up": "ใช้งานได้"
},
"incidents": {
"errorLoading": "ไม่สามารถโหลดเหตุการณ์ได้"
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Bakım",
"paused": "Duraklatıldı",
"up": "Çalışıyor"
},
"incidents": {
"errorLoading": "Olaylar yüklenemedi."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Обслуговування",
"paused": "Призупинено",
"up": "Працює"
},
"incidents": {
"errorLoading": "Не вдалося завантажити інциденти."
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "Bảo trì",
"paused": "Tạm dừng",
"up": "Hoạt động"
},
"incidents": {
"errorLoading": "Không thể tải sự cố."
}
},
"statusBar": {
+3
View File
@@ -1378,6 +1378,9 @@
"maintenance": "维护中",
"paused": "已暂停",
"up": "运行中"
},
"incidents": {
"errorLoading": "无法加载事件。"
}
},
"statusBar": {
+3
View File
@@ -1377,6 +1377,9 @@
"maintenance": "維護中",
"paused": "已暫停",
"up": "運行中"
},
"incidents": {
"errorLoading": "無法載入事件。"
}
},
"statusBar": {
+52 -5
View File
@@ -466,6 +466,12 @@
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerAlertOnStopped": {
"type": "boolean"
},
"dockerAlertOnUnhealthy": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
@@ -523,6 +529,8 @@
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"dockerAlertOnStopped",
"dockerAlertOnUnhealthy",
"teamId",
"userId",
"createdAt",
@@ -694,6 +702,12 @@
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerAlertOnStopped": {
"type": "boolean"
},
"dockerAlertOnUnhealthy": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
@@ -753,6 +767,8 @@
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"dockerAlertOnStopped",
"dockerAlertOnUnhealthy",
"teamId",
"userId",
"createdAt",
@@ -7186,6 +7202,12 @@
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerAlertOnStopped": {
"type": "boolean"
},
"dockerAlertOnUnhealthy": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
@@ -7245,6 +7267,8 @@
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"dockerAlertOnStopped",
"dockerAlertOnUnhealthy",
"teamId",
"userId",
"createdAt",
@@ -7761,6 +7785,12 @@
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerAlertOnStopped": {
"type": "boolean"
},
"dockerAlertOnUnhealthy": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
@@ -7820,6 +7850,8 @@
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"dockerAlertOnStopped",
"dockerAlertOnUnhealthy",
"teamId",
"userId",
"createdAt",
@@ -9308,6 +9340,12 @@
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerAlertOnStopped": {
"type": "boolean"
},
"dockerAlertOnUnhealthy": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
@@ -10033,6 +10071,14 @@
"type": "boolean",
"default": false
},
"dockerAlertOnStopped": {
"type": "boolean",
"default": false
},
"dockerAlertOnUnhealthy": {
"type": "boolean",
"default": false
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -10600,6 +10646,12 @@
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerAlertOnStopped": {
"type": "boolean"
},
"dockerAlertOnUnhealthy": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
@@ -13307,11 +13359,6 @@
},
"maxItems": 10
},
"egressPollIntervalSeconds": {
"type": "integer",
"minimum": 5,
"maximum": 600
},
"egressNotifications": {
"type": "array",
"items": {
+7
View File
@@ -18,6 +18,7 @@
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.23",
"dockerode": "5.0.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
@@ -5803,6 +5804,12 @@
"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": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+2 -1
View File
@@ -15,7 +15,7 @@
"worker": "QUEUE_MODE=worker node ./dist/index.js",
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
"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",
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"lint": "eslint .",
@@ -42,6 +42,7 @@
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.23",
"dockerode": "5.0.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
@@ -35,11 +35,18 @@ class NotificationController implements INotificationController {
testNotification = catchAsync(async (req: Request, res: Response) => {
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({
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 },
});
});
@@ -9,6 +9,8 @@ import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { IEgressStateService } from "@/domain/egress/egress-state.service.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js";
const SERVICE_NAME = "SettingsController";
export interface ISettingsController {
getAppSettings: RequestHandler;
updateAppSettings: RequestHandler;
@@ -119,7 +121,7 @@ class SettingsController implements ISettingsController {
const html = await this.emailService.buildEmail("testEmailTemplate", context);
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;
try {
@@ -142,8 +144,11 @@ class SettingsController implements ISettingsController {
// Surface the underlying SMTP failure: diagnosing the settings is the whole
// point of the test email endpoint.
throw new AppError({
message: error instanceof Error ? `Failed to send test email. ${error.message}` : "Failed to send test email.",
status: 500,
message: error instanceof AppError ? error.message : "Failed to send test email.",
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,
imageValidation,
resolveStatusPageQueryValidation,
getPublicMonitorIncidentsParamValidation,
getPublicMonitorIncidentsQueryValidation,
} from "@/api/validation/statusPageValidation.js";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
@@ -17,6 +19,7 @@ export interface IStatusPageController {
createStatusPage: RequestHandler;
updateStatusPage: RequestHandler;
getStatusPageByUrl: RequestHandler;
getPublicMonitorIncidents: RequestHandler;
resolveStatusPageByDomain: RequestHandler;
getStatusPagesByTeamId: 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) => {
const { range } = resolveStatusPageQueryValidation.parse(req.query);
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;
}
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;
}
@@ -14,6 +14,7 @@ export const createStatusPageRoutes = (
router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.updateStatusPage);
router.get("/resolve", statusPageController.resolveStatusPageByDomain);
router.get("/:url", verifyStatusPageAccess, statusPageController.getStatusPageByUrl);
router.get("/:url/incidents/:monitorId", verifyStatusPageAccess, statusPageController.getPublicMonitorIncidents);
router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.deleteStatusPage);
return router;
};
+20 -4
View File
@@ -15,7 +15,7 @@ import {
import { DateRanges, SortOrders } from "@/types/query.js";
import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js";
import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js";
import { isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { isCaptureDockerUrl, isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { X509Certificate } from "node:crypto";
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) => {
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({
code: "custom",
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?: string;
url?: string;
@@ -214,6 +220,8 @@ export const createMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerAlertOnStopped: z.boolean().optional(),
dockerAlertOnUnhealthy: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
@@ -226,6 +234,7 @@ export const createMonitorBodyValidation = z
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl)
.superRefine(refineCaptureSecret)
.superRefine(refineDockerTls("create"));
export const editMonitorBodyValidation = z
@@ -263,6 +272,8 @@ export const editMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerAlertOnStopped: z.boolean().optional(),
dockerAlertOnUnhealthy: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
@@ -343,6 +354,8 @@ const importedMonitorSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
dockerLogsEnabled: z.boolean().default(false),
dockerAlertOnStopped: z.boolean().default(false),
dockerAlertOnUnhealthy: z.boolean().default(false),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
createdAt: z.string().optional(),
@@ -353,7 +366,8 @@ const importedMonitorSchema = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineCaptureSecret);
export const importMonitorsBodyValidation = z.object({
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)),
geoCheckInterval: z.number(),
dockerLogsEnabled: z.boolean(),
dockerAlertOnStopped: z.boolean(),
dockerAlertOnUnhealthy: z.boolean(),
dockerTlsCa: z.string().optional(),
dockerTlsCert: z.string().optional(),
dockerTlsKeySet: z.boolean().optional(),
@@ -1,5 +1,5 @@
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 { z } from "zod";
@@ -56,7 +56,6 @@ export const updateAppSettingsBodyValidation = z
egressCheckEnabled: z.boolean().optional(),
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(),
})
.strip()
@@ -20,6 +20,15 @@ export const getStatusPageParamValidation = z.object({
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({
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"),
+1 -1
View File
@@ -99,7 +99,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
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 diagnosticService = new DiagnosticService(db);
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
+27 -29
View File
@@ -17,12 +17,9 @@ import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { IQueueWorker, JobHandlers } from "@/worker/worker.interface.js";
import { WorkerHelper } from "@/worker/worker.helper.js";
import { CheckProducer } from "@/worker/worker.check-producer.js";
import { CheckEvaluator } from "@/worker/worker.check-evaluator.js";
import { GeoChecksPipeline } from "@/worker/worker.check-pipeline.js";
import { GeoChecksPipeline } from "@/worker/worker.geo-pipeline.js";
import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
import { IncidentReactor } from "@/worker/reactors/reactor.incident.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 { DNSProvider } from "@/service/network/DNSProvider.js";
import { AppError } from "@/utils/AppError.js";
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
@@ -82,7 +80,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker, encryptionService);
const dockerProvider = new DockerProvider(logger, Docker, encryptionService, httpProvider);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
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 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 monitorStatusPolicy = new MonitorStatusPolicy();
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, networkService, proxyResolver, logger);
// ***********************
// Reactors and dispatcher
// Handles notifications and incidents
// ***********************
const notificationReactor = new NotificationReactor(notificationsService);
const incidentReactor = new IncidentReactor(incidentService);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
// ***********************
// Check producer/evaluator
// Handles creating and evaluatiog checks
// ***********************
const checkProducer = new CheckProducer(
const pipeline = new WorkerPipeline({
logger,
monitorsRepository,
maintenanceWindowsRepository,
checksRepository,
jobsRepository,
checkService,
networkService,
proxyResolver,
bufferService,
dockerLogsService,
logger
);
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
egressService,
statusService,
dispatcher: reactorDispatcher,
});
const geoCheckPipeline = new GeoChecksPipeline(monitorsRepository, maintenanceWindowsRepository, geoChecksService, bufferService, logger);
// ***********************
// Worker
@@ -153,19 +148,22 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
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({
logger,
isDbConnected: () => mongoose.connection.readyState === 1,
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
bufferService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
dispatcher: reactorDispatcher,
helper: workerHelper,
handlers,
queueWorkersRepository,
queueMode: envSettings.queueMode,
queuePrimaryProcesses: envSettings.queuePrimaryProcesses,
@@ -1,6 +1,6 @@
import { Schema, model, type Types } from "mongoose";
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"> {
_id: Types.ObjectId;
@@ -45,7 +45,6 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
globalProxyId: { type: String, default: null },
egressCheckEnabled: { type: Boolean, default: false },
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
},
{ 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 { AppSettingsModel, type AppSettingsDocument } from "@/domain/app-settings/app-settings.model.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 {
private toEntity = (doc: AppSettingsDocument): Settings => {
@@ -33,7 +33,6 @@ class MongoSettingsRepository implements ISettingsRepository {
globalProxyId: doc.globalProxyId ?? undefined,
egressCheckEnabled: doc.egressCheckEnabled ?? false,
egressCheckTargets: doc.egressCheckTargets ?? [...DEFAULT_EGRESS_TARGETS],
egressPollIntervalSeconds: doc.egressPollIntervalSeconds ?? DEFAULT_EGRESS_POLL_INTERVAL_SECONDS,
egressNotifications: doc.egressNotifications ?? [],
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
@@ -54,7 +54,6 @@ export interface Settings {
// Egress self-check (see domain/egress)
egressCheckEnabled: boolean;
egressCheckTargets: string[];
egressPollIntervalSeconds: number; // recovery poll cadence while degraded, not a general schedule
egressNotifications: string[]; // Notification ids to receive the "egress recovered" alert
createdAt: string;
updatedAt: string;
@@ -1,5 +1,6 @@
import CheckModel from "@/domain/checks/check.model.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";
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.type": "docker",
createdAt: { $gte: dates.start, $lte: dates.end },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
});
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.type": "docker",
createdAt: { $gte: dates.start, $lte: dates.end },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
status: true,
});
return { totalChecks: count };
@@ -28,6 +31,7 @@ export const getDockerStats = async (monitorId: string, dates: DateRange, dateSt
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "docker",
createdAt: { $gte: dates.start, $lte: dates.end },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
},
},
{
@@ -48,6 +52,7 @@ export const getDockerLatestCheck = async (monitorId: string) =>
CheckModel.findOne({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "docker",
...EXCLUDE_DEGRADED_EGRESS_MATCH,
})
.sort({ createdAt: -1 })
.select("containers containerSummary createdAt")
@@ -65,6 +70,7 @@ export const getDockerContainerStats = async (
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "docker",
createdAt: { $gte: dates.start, $lte: dates.end },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
"containers.name": containerName,
},
},
@@ -88,6 +94,7 @@ export const getDockerContainerLatestCheck = async (monitorId: string, container
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "docker",
"containers.name": containerName,
...EXCLUDE_DEGRADED_EGRESS_MATCH,
})
.sort({ createdAt: -1 })
.select("containers createdAt")
@@ -1,6 +1,7 @@
import { CheckModel } from "@/domain/checks/check.model.js";
import mongoose from "mongoose";
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 HardwareUpChecks = { totalChecks: number };
@@ -70,6 +71,7 @@ export const getHardwareTotalChecks = async (monitorId: string, dates: DateRange
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "hardware",
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.type": "hardware",
createdAt: { $gte: dates.start, $lte: dates.end },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
status: true,
});
return { totalChecks: count };
@@ -95,6 +98,7 @@ export const getHardwareStats = async (monitorId: string, dates: DateRange, date
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
"metadata.type": "hardware",
createdAt: { $gte: dates.start, $lte: dates.end },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
},
},
{ $sort: { createdAt: 1 } },
+7
View File
@@ -1,5 +1,6 @@
import { Schema, model, Types } from "mongoose";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { EgressStatuses } from "@/domain/egress/egress.type.js";
import type {
Check,
CheckAudits,
@@ -219,6 +220,7 @@ const dockerContainerSchema = new Schema<DockerContainerInfo>(
startedAt: { type: String },
ports: { type: [dockerContainerPortSchema], default: undefined },
mounts: { type: [dockerContainerMountSchema], default: undefined },
exitCode: { type: Number, default: undefined },
},
{ _id: false }
);
@@ -337,6 +339,11 @@ const CheckSchema = new Schema<CheckDocument>(
type: containerSummarySchema,
default: undefined,
},
egressStatus: {
type: String,
enum: EgressStatuses,
default: undefined,
},
},
{
timestamps: true,
+7
View File
@@ -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;
@@ -16,6 +16,7 @@ import type {
} from "@/domain/checks/check.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.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 { getDateFormat, getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
@@ -179,6 +180,7 @@ class MongoChecksRepository implements IChecksRepository {
audits: mapAudits(doc.audits),
containers: doc.containers,
containerSummary: doc.containerSummary,
egressStatus: doc.egressStatus,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -348,6 +350,7 @@ class MongoChecksRepository implements IChecksRepository {
const baseMatch = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
createdAt: { $gte: getDateForRange(dateRange) },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
};
const [totalResult, downResult] = await Promise.all([
@@ -393,6 +396,7 @@ class MongoChecksRepository implements IChecksRepository {
$match: {
"metadata.monitorId": { $in: objectIds },
createdAt: { $gte: windowStart },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
},
},
{
@@ -473,6 +477,7 @@ class MongoChecksRepository implements IChecksRepository {
const matchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
};
const [result] = await CheckModel.aggregate([
{ $match: matchStage },
@@ -638,6 +643,7 @@ class MongoChecksRepository implements IChecksRepository {
const matchStage = {
"metadata.monitorId": monitorObjectId,
createdAt: { $gte: startDate, $lte: endDate },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
};
const [result] = await CheckModel.aggregate([
+12 -4
View File
@@ -135,6 +135,17 @@ export class CheckService implements ICheckService {
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) => {
const statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]> = {
monitorId: check.metadata.monitorId,
@@ -145,10 +156,7 @@ export class CheckService implements ICheckService {
message: check.message,
responseTime: check.responseTime,
// re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data
payload:
check.metadata.type === "hardware"
? ({ data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload)
: undefined,
payload: this.toPayload(check),
};
return statusResponse;
};
+5
View File
@@ -1,4 +1,5 @@
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 type { Response } from "got";
@@ -116,6 +117,10 @@ export interface Check {
seo?: number;
performance?: number;
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;
updatedAt: string;
}
+47
View File
@@ -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})`;
};
+1
View File
@@ -55,6 +55,7 @@ export interface DockerContainerInfo {
startedAt?: string; // ISO date
ports?: DockerContainerPort[];
mounts?: DockerContainerMount[];
exitCode?: number;
}
export interface DockerContainerSummary {
+6 -11
View File
@@ -7,7 +7,7 @@ import type { INetworkService } from "@/service/networkService.js";
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
import type { ILogger } from "@/utils/logger.js";
import {
DEFAULT_EGRESS_POLL_INTERVAL_SECONDS,
EGRESS_RECOVERY_POLL_SECONDS,
DEFAULT_EGRESS_TARGETS,
type EgressAssessment,
type EgressProbeResult,
@@ -26,7 +26,7 @@ const RECOVERY_JOB_TYPE = "egress" as const;
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.
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.
const PROBE_MONITOR_ID = "egress-probe";
@@ -136,11 +136,6 @@ export class EgressService implements IEgressService {
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
// ****************************
@@ -181,7 +176,7 @@ export class EgressService implements IEgressService {
const state = await this.egressStateRepository.findSingleton();
if (state.status === "degraded") {
// 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";
}
@@ -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
// 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);
this.logger.warn({
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,
// so re-arming while degraded never pushes a pending run back.
private scheduleRecoveryCheck = async (pollIntervalSeconds: number | undefined, reschedule: boolean) => {
const intervalMs = this.toIntervalMs(pollIntervalSeconds);
private scheduleRecoveryCheck = async (reschedule: boolean) => {
const intervalMs = EGRESS_RECOVERY_POLL_SECONDS * 1000;
const seed: JobSeed = {
id: RECOVERY_JOB_ID,
type: RECOVERY_JOB_TYPE,
+5 -3
View File
@@ -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,
// 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_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;
// 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
// process share one view of the state (the queue worker may run separately from the API).
export interface EgressState {
@@ -82,6 +82,7 @@ IncidentSchema.index({ teamId: 1, startTime: -1 });
IncidentSchema.index({ status: 1, startTime: -1 });
IncidentSchema.index({ resolutionType: 1, status: 1 });
IncidentSchema.index({ resolvedBy: 1, status: 1 });
IncidentSchema.index({ monitorId: 1, startTime: -1 });
IncidentSchema.index({ createdAt: -1 });
const IncidentModel = model<IncidentDocument>("Incident", IncidentSchema);
@@ -17,6 +17,7 @@ export interface IIncidentsRepository {
monitorId?: string,
resolutionType?: string
): Promise<Incident[]>;
findByMonitorIdAndDate(monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise<Incident[]>;
findSummaryByTeamId(teamId: string, limit?: number): Promise<IncidentSummary>;
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);
};
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>) => {
const updatedIncident = await IncidentModel.findOneAndUpdate(
{ _id: new mongoose.Types.ObjectId(incidentId), teamId: new mongoose.Types.ObjectId(teamId) },
@@ -1,6 +1,6 @@
const SERVICE_NAME = "incidentService";
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 { getDateForRange } from "@/utils/dataUtils.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 { Incident, IncidentSummary } from "@/domain/incidents/incident.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 { ILogger } from "@/utils/logger.js";
import { DateRange } from "@/types/query.js";
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
export interface IIncidentService {
handleIncident(
@@ -106,6 +107,12 @@ export class IncidentService implements IIncidentService {
};
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) {
return "Threshold breach detected";
}
@@ -20,6 +20,11 @@ export interface Incident {
updatedAt: string;
}
export type PublicIncident = Pick<
Incident,
"id" | "monitorId" | "status" | "startTime" | "endTime" | "resolutionType" | "message" | "statusCode" | "createdAt"
>;
export interface IncidentSummaryTopMonitor {
monitorId: string;
monitorName: string | null;
@@ -307,6 +307,14 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
dockerAlertOnStopped: {
type: Boolean,
default: false,
},
dockerAlertOnUnhealthy: {
type: Boolean,
default: false,
},
dockerTlsCa: {
type: String,
},
@@ -468,6 +468,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dockerLogsEnabled: doc.dockerLogsEnabled ?? false,
dockerAlertOnStopped: doc.dockerAlertOnStopped ?? false,
dockerAlertOnUnhealthy: doc.dockerAlertOnUnhealthy ?? false,
dockerTlsCa: doc.dockerTlsCa ?? undefined,
dockerTlsCert: doc.dockerTlsCert ?? undefined,
dockerTlsKeySet: doc.dockerTlsKeySet ?? false,
+25 -1
View File
@@ -34,7 +34,7 @@ import { IJobScheduler } from "@/worker/worker.interface.js";
import { DateRange } from "@/types/query.js";
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
import { IEncryptionService } from "@/service/encryption/encryptionService.js";
import { isDockerTlsUrl } from "@/utils/dockerHost.js";
import { isCaptureDockerUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
const SERVICE_NAME = "MonitorService";
@@ -194,6 +194,9 @@ export class MonitorService implements IMonitorService {
// Encrypt docker TLS keys
this.encryptDockerTls(body, "create");
if (body.type === "docker" && isCaptureDockerUrl(body.url)) {
body.dockerLogsEnabled = false;
}
const monitor = await this.monitorsRepository.create(body, teamId, userId);
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 });
await this.scheduler.updateJob(editedMonitor);
return editedMonitor;
@@ -749,6 +753,26 @@ export class MonitorService implements IMonitorService {
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") => {
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 { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js";
import { DockerLogPage } from "@/domain/docker/docker-log.type.js";
import { isDockerSocketUrl } from "@/utils/dockerHost.js";
export const HttpStatusCodes = [
...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 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 type MonitorStatus = (typeof MonitorStatuses)[number];
@@ -109,6 +150,8 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerAlertOnStopped?: boolean;
dockerAlertOnUnhealthy?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
// EncryptionService ciphertext. Normally excluded
@@ -1,6 +1,6 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import type { MonitorActionDecision } from "@/worker/worker.helper.js";
import type { DockerStatusPayload, HardwareStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import type { MonitorActionDecision } from "@/worker/worker.interface.js";
import type {
NotificationMessage,
NotificationType,
@@ -8,6 +8,7 @@ import type {
ThresholdBreach,
NotificationContent,
} from "@/domain/notifications/notification.type.js";
import { describeContainerBreach, findContainerBreaches } from "@/domain/docker/docker-alert.js";
export interface INotificationMessageBuilder {
buildMessage(
@@ -59,32 +60,29 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
return "monitor_down";
}
// Threshold breach (only if not down)
if (decision.notificationReason === "threshold_breach") {
return "threshold_breach";
const isDocker = monitor.type === "docker";
switch (decision.notificationReason) {
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 {
switch (type) {
case "monitor_up":
return "success";
case "monitor_down":
return "critical";
case "container_breach":
return "warning";
case "threshold_breach":
return "warning";
case "monitor_up":
case "container_resolved":
return "success";
case "threshold_resolved":
return "success";
case "test":
@@ -104,6 +102,10 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder {
return this.buildThresholdBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<HardwareStatusPayload>);
case "threshold_resolved":
return this.buildThresholdResolvedContent(monitor);
case "container_breach":
return this.buildContainerBreachContent(monitor, monitorStatusResponse as MonitorStatusResponse<DockerStatusPayload>);
case "container_resolved":
return this.buildContainersRecoveredContent(monitor);
default:
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 {
return {
title: `Monitor: ${monitor.name}`,
@@ -5,7 +5,7 @@ import type { NotificationMessage } from "@/domain/notifications/notification.ty
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { INotificationsRepository } from "@/domain/notifications/notification.repository.interface.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 { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js";
@@ -148,7 +148,17 @@ export class NotificationsService implements INotificationsService {
testAllNotifications = async (notificationIds: string[]) => {
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 succeeded = outcomes.filter(Boolean).length;
const failed = outcomes.length - succeeded;
@@ -71,7 +71,8 @@ export interface AlertDiscordPayload {
* 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";
@@ -1,6 +1,7 @@
const SERVICE_NAME = "EmailProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { AppError } from "@/utils/AppError.js";
import { buildTestEmail } from "@/domain/notifications/providers/utils.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
import type { ILogger } from "@/utils/logger.js";
@@ -40,11 +41,13 @@ export class EmailProvider extends NotificationProvider {
return true;
} catch (error: unknown) {
this.logger.warn({
message: "Email test alert failed",
message: `Email test alert failed: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "sendTestAlert",
details: error instanceof AppError ? error.details : undefined,
stack: error instanceof Error ? error.stack : undefined,
});
if (error instanceof AppError) throw error;
return false;
}
}
@@ -73,9 +76,10 @@ export class EmailProvider extends NotificationProvider {
return true;
} catch (error: unknown) {
this.logger.warn({
message: "Email notification failed",
message: `Email notification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "sendMessage",
details: error instanceof AppError ? error.details : undefined,
stack: error instanceof Error ? error.stack : undefined,
});
return false;
@@ -92,6 +96,10 @@ export class EmailProvider extends NotificationProvider {
return `Monitor ${message.monitor.name} threshold exceeded`;
case "threshold_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:
return `Alert: ${message.monitor.name}`;
}
@@ -3,7 +3,7 @@ import got from "got";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import type { AlertMatrixPayload, Notification } 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";
export class MatrixProvider extends NotificationProvider {
@@ -172,10 +172,10 @@ export class MatrixProvider extends NotificationProvider {
// Incident link (if incident exists)
if (message.content.incident) {
const incidentUrl = `${message.clientHost}/infrastructure/${message.monitor.id}`;
const url = incidentUrl(message);
plainLines.push("");
plainLines.push(`View Incident: ${incidentUrl}`);
htmlLines.push(`<p><a href="${this.escapeHtml(incidentUrl)}">View Incident</a></p>`);
plainLines.push(`View Incident: ${url}`);
htmlLines.push(`<p><a href="${this.escapeHtml(url)}">View Incident</a></p>`);
}
// Footer with timestamp
@@ -2,7 +2,7 @@ const SERVICE_NAME = "NtfyProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.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";
export class NtfyProvider extends NotificationProvider {
@@ -142,7 +142,7 @@ export class NtfyProvider extends NotificationProvider {
}
if (message.content.incident) {
lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`);
lines.push("", incidentUrl(message));
}
return lines.join("\n");
@@ -75,7 +75,8 @@ export class PagerDutyProvider extends NotificationProvider {
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): AlertPagerDutyPayload {
// 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
const severityMap: Record<string, string> = {
@@ -2,7 +2,7 @@ const SERVICE_NAME = "PushoverProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.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";
export class PushoverProvider extends NotificationProvider {
@@ -101,7 +101,7 @@ export class PushoverProvider extends NotificationProvider {
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
lines.push(`View Incident: ${incidentUrl(message)}`);
}
return lines.join("\n");
@@ -1,5 +1,5 @@
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 { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
@@ -139,19 +139,7 @@ export class RocketChatProvider extends NotificationProvider {
return undefined;
}
if (message.monitor.type === "hardware") {
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;
return `${clientHost}/${getMonitorPath(message.monitor.type as MonitorType)}/${message.monitor.id}`;
}
private severityColor(severity: NotificationMessage["severity"]): string {
@@ -1,7 +1,7 @@
const SERVICE_NAME = "SignalgridProvider";
import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
import { getTestMessage } from "@/domain/notifications/providers/utils.js";
import { getTestMessage, incidentUrl } from "@/domain/notifications/providers/utils.js";
import got from "got";
export class SignalgridProvider extends NotificationProvider {
@@ -115,7 +115,7 @@ export class SignalgridProvider extends NotificationProvider {
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
lines.push(`View Incident: ${incidentUrl(message)}`);
}
return lines.join("\n");
@@ -2,7 +2,7 @@ const SERVICE_NAME = "SlackProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.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, { HTTPError } from "got";
export class SlackProvider extends NotificationProvider {
@@ -161,7 +161,7 @@ export class SlackProvider extends NotificationProvider {
text: "View Incident",
emoji: true,
},
url: `${message.clientHost}/infrastructure/${message.monitor.id}`,
url: incidentUrl(message),
style: "primary",
},
],
@@ -2,7 +2,7 @@ const SERVICE_NAME = "TeamsProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.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, { HTTPError } from "got";
// Types for Adaptive Card elements
@@ -254,7 +254,7 @@ export class TeamsProvider extends NotificationProvider {
actions.push({
type: "Action.OpenUrl",
title: "View Incident",
url: `${message.clientHost}/incidents/${message.content.incident.id}`,
url: incidentUrl(message),
});
}
@@ -2,7 +2,7 @@ const SERVICE_NAME = "TelegramProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.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";
export class TelegramProvider extends NotificationProvider {
@@ -101,7 +101,7 @@ export class TelegramProvider extends NotificationProvider {
if (message.content.incident) {
lines.push("");
lines.push(`<a href="${message.clientHost}/incidents/${message.monitor.id}">View Incident</a>`);
lines.push(`<a href="${incidentUrl(message)}">View Incident</a>`);
}
return lines.join("\n");
@@ -1,4 +1,8 @@
import { IEmailService } from "@/service/emailService.js";
import type { NotificationMessage } from "@/domain/notifications/notification.type.js";
export const incidentUrl = (message: NotificationMessage): string => `${message.clientHost}/incidents/${message.monitor.id}`;
export const buildTestEmail = async (emailService: IEmailService) => {
const context = { testName: "Monitoring System" };
const html = await emailService.buildEmail("testEmailTemplate", context);
@@ -2,7 +2,7 @@ const SERVICE_NAME = "WebhookProvider";
import type { Notification } from "@/domain/notifications/notification.type.js";
import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.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";
export class WebhookProvider extends NotificationProvider {
@@ -74,7 +74,7 @@ export class WebhookProvider extends NotificationProvider {
// Incident link
if (message.content.incident) {
lines.push(`[View Incident](${message.clientHost}/infrastructure/${message.monitor.id})`);
lines.push(`[View Incident](${incidentUrl(message)})`);
}
// Return webhook payload with both text and structured data
@@ -1,6 +1,13 @@
import { type IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import type { Incident, PublicIncident } from "@/domain/incidents/incident.type.js";
import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import type { DailyCheckBucket } from "@/domain/checks/check.type.js";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
import timezone from "dayjs/plugin/timezone.js";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
@@ -12,8 +19,9 @@ import {
import { AppError } from "@/utils/AppError.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import type { DailyCheckBucket } from "@/domain/checks/check.type.js";
dayjs.extend(utc);
dayjs.extend(timezone);
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
@@ -21,6 +29,7 @@ export interface IStatusPageService {
getStatusPageByCustomDomain(customDomain: string): Promise<StatusPage>;
getStatusPagesByTeamId(teamId: string): Promise<StatusPage[]>;
getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise<PublicStatusPagePayload>;
getPublicMonitorIncidents(url: string, monitorId: string, date: string, requesterTeamId?: string): Promise<PublicIncident[]>;
updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
deleteStatusPage(statusPageId: string, teamId: string): Promise<StatusPage>;
@@ -31,7 +40,8 @@ export class StatusPageService implements IStatusPageService {
private statusPagesRepository: IStatusPagesRepository,
private settingsService: ISettingsService,
private monitorsRepository: IMonitorsRepository,
private checksRepository: IChecksRepository
private checksRepository: IChecksRepository,
private incidentsRepository: IIncidentsRepository
) {}
private assertCustomDomainAllowed = (customDomain: string | null | undefined) => {
@@ -95,6 +105,18 @@ export class StatusPageService implements IStatusPageService {
return base;
};
private toPublicIncident = (incident: Incident): PublicIncident => ({
id: incident.id,
monitorId: incident.monitorId,
status: incident.status,
startTime: incident.startTime,
endTime: incident.endTime,
resolutionType: incident.resolutionType,
message: incident.message ?? null,
statusCode: incident.statusCode ?? null,
createdAt: incident.createdAt,
});
createStatusPage = async (
userId: string,
teamId: string,
@@ -167,6 +189,29 @@ export class StatusPageService implements IStatusPageService {
};
};
getPublicMonitorIncidents = async (url: string, monitorId: string, date: string, requesterTeamId?: string): Promise<PublicIncident[]> => {
const statusPage = await this.getStatusPageByUrl(url);
// Ensure the status page is public or the requester is the owner
if (!statusPage.isPublished) {
if (!requesterTeamId || statusPage.teamId !== requesterTeamId) {
throw new AppError({ message: "Forbidden", status: 403 });
}
}
// Ensure the requested monitor actually belongs to this status page
if (!statusPage.monitors.includes(monitorId)) {
throw new AppError({ message: "Monitor not found on this status page", status: 404 });
}
const tz = statusPage.timezone ?? "Etc/UTC";
const dateStart = dayjs.tz(date, tz).startOf("day").toDate();
const dateEnd = dayjs.tz(date, tz).endOf("day").toDate();
const incidents = await this.incidentsRepository.findByMonitorIdAndDate(monitorId, statusPage.teamId, dateStart, dateEnd);
return incidents.map(this.toPublicIncident);
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data));
const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData);
+6 -54
View File
@@ -3,18 +3,15 @@ import type { GeoCheck } from "@/domain/geo-checks/geo-check.type.js";
import type { IGeoChecksService } from "../domain/geo-checks/geo-check.service.js";
import type { ILogger } from "@/utils/logger.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { ICheckService } from "../domain/checks/check.service.js";
import { DockerLog } from "@/domain/docker/docker-log.type.js";
import { IDockerLogsService } from "@/domain/docker/docker-log.service.js";
import { PendingCheck } from "@/domain/jobs/job.type.js";
const SERVICE_NAME = "BufferService";
export type CheckIngest = (checks: Check[]) => Promise<void>;
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
addDockerLogToBuffer(dockerLog: DockerLog): void;
ingestChecks(checks: Check[]): Promise<void>;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>;
@@ -31,28 +28,20 @@ export class BufferService implements IBufferService {
private geoBuffer: GeoCheck[];
private dockerLogBuffer: DockerLog[];
private bufferTimer: NodeJS.Timeout | null = null;
private checksService: ICheckService;
private geoChecksService: IGeoChecksService;
private dockerLogsService: IDockerLogsService;
private jobsRepository: IJobsRepository;
// Checks that are stored but whose evaluate row could not be armed. Retried on the next flush so they are never unevaluated.
private unarmed: Map<string, PendingCheck[]> = new Map();
constructor(
logger: ILogger,
checkService: ICheckService,
geoChecksService: IGeoChecksService,
dockerLogsService: IDockerLogsService,
settingsService: ISettingsService,
jobsRepository: IJobsRepository
private ingest: CheckIngest
) {
this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 1000 : 1000 * 60 * 1; // 1 minute
this.logger = logger;
this.checksService = checkService;
this.geoChecksService = geoChecksService;
this.dockerLogsService = dockerLogsService;
this.jobsRepository = jobsRepository;
this.SERVICE_NAME = SERVICE_NAME;
this.buffer = [];
this.geoBuffer = [];
@@ -104,38 +93,6 @@ export class BufferService implements IBufferService {
}
}
ingestChecks = async (checks: Check[]) => {
if (checks.length > 0) await this.checksService.createChecks(checks);
// Group by monitor, starting from any entries a previous flush failed to arm
const byMonitor = this.unarmed;
this.unarmed = new Map();
for (const check of checks) {
const pending = byMonitor.get(check.metadata.monitorId) ?? [];
pending.push({ checkId: check.id, createdAt: new Date(check.createdAt).getTime() });
byMonitor.set(check.metadata.monitorId, pending);
}
if (byMonitor.size === 0) return;
const now = Date.now();
const ops: Promise<boolean>[] = [];
for (const [monitorId, pendingChecks] of byMonitor) {
ops.push(
this.jobsRepository.upsertEvaluate(monitorId, pendingChecks, now).catch((error: unknown) => {
// The checks are already stored, so a failed arm is retried on the next flush rather than dropped
this.unarmed.set(monitorId, pendingChecks);
this.logger.error({
message: `Could not arm evaluation for monitor ${monitorId}, retrying on next flush: ${error instanceof Error ? error.message : String(error)}`,
service: this.SERVICE_NAME,
method: "ingestChecks",
});
return false;
})
);
}
await Promise.all(ops);
};
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -160,19 +117,14 @@ export class BufferService implements IBufferService {
}
async flushBuffer() {
if (this.buffer.length === 0 && this.unarmed.size === 0) {
return;
}
// Take the batch first so a write that drains it can't be appended to mid-flush
const batch = this.buffer;
this.buffer = [];
try {
this.logger.debug({
message: `Flushing ${batch.length} checks to database`,
service: this.SERVICE_NAME,
method: "flushBuffer",
});
await this.ingestChecks(batch);
if (batch.length > 0) {
this.logger.debug({ message: `Flushing ${batch.length} checks to database`, service: this.SERVICE_NAME, method: "flushBuffer" });
}
await this.ingest(batch);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
+33 -3
View File
@@ -12,6 +12,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SERVICE_NAME = "EmailService";
const SMTP_FAILURE_REASONS: Record<string, string> = {
EAUTH: "rejected the login. Check the user name and password",
ENOAUTH: "requires a login. Set the user name and password",
EDNS: "could not be resolved. Check the host name",
ECONNECTION: "refused the connection. Check the host and port",
ESOCKET: "refused the connection. Check the host and port",
ETIMEDOUT: "did not respond. Check the port and the secure setting",
ETLS: "failed the TLS handshake. Check the secure and TLS settings",
EPROTOCOL: "sent an invalid response. Check the port and the secure setting",
};
const describeSmtpFailure = (error: unknown, host: string, port: number): string => {
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code: unknown }).code) : undefined;
const reason = (code !== undefined && SMTP_FAILURE_REASONS[code]) || "failed verification";
return `SMTP host ${host}:${port} ${reason}.`;
};
type MjmlFn = typeof mjml2html;
type FileSystem = typeof fs;
type PathModule = typeof path;
@@ -123,6 +140,15 @@ export class EmailService implements IEmailService {
systemEmailRejectUnauthorized,
} = config;
if (!systemEmailHost) {
throw new AppError({
message: "Email is not configured. Set the system email host in settings.",
status: 400,
service: SERVICE_NAME,
method: "sendEmail",
});
}
const emailConfig = {
host: systemEmailHost,
port: Number(systemEmailPort),
@@ -146,17 +172,20 @@ export class EmailService implements IEmailService {
try {
await this.transporter.verify();
} catch (error: unknown) {
const cause = error instanceof Error ? error.message : "Unknown error";
this.logger.warn({
message: "Email transporter verification failed",
message: `Email transporter verification failed: ${cause}`,
service: SERVICE_NAME,
method: "verifyTransporter",
details: { host: systemEmailHost, port: emailConfig.port },
stack: error instanceof Error ? error.stack : undefined,
});
throw new AppError({
message: "Email transporter verification failed",
message: describeSmtpFailure(error, systemEmailHost, emailConfig.port),
status: 502,
service: SERVICE_NAME,
method: "sendEmail",
details: { cause: error instanceof Error ? error.message : "Unknown error" },
details: { cause },
});
}
@@ -180,6 +209,7 @@ export class EmailService implements IEmailService {
});
throw new AppError({
message: "Failed to send email",
status: 502,
service: SERVICE_NAME,
method: "sendEmail",
details: { cause: error instanceof Error ? error.message : "Unknown error" },
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more