Compare commits

...
Author SHA1 Message Date
Alexander Holliday a5766a55a9 Merge pull request #4000 from bluewave-labs/feat/express-5-bump
feat: migrate to Express 5
2026-09-25 13:19:47 -07:00
Alex Holliday c5902b397a bump node and npm 2026-09-25 13:15:58 -07:00
Alex Holliday e52ec2d6ee fix dependencies 2026-09-25 13:06:02 -07:00
Alex Holliday a2c978527f bump express to 5, migrate, bump multer to 2 2026-09-25 12:57:35 -07:00
Alexander Holliday 224dc60309 Merge pull request #3999 from bluewave-labs/chore/buffer-cleanup
chore: buffer cleanup
2026-09-25 11:54:35 -07:00
Alex Holliday d8e4c775d5 update tests 2026-09-25 11:51:10 -07:00
Alex Holliday a07d0059b7 remove unreachable catch blocks 2026-09-25 11:50:13 -07:00
Alexander Holliday 1a48d303f7 Merge pull request #3998 from bluewave-labs/chore/cleanup-controller-utils
chore: remove dead code
2026-09-25 11:47:37 -07:00
Alex Holliday 5bb9ca970c remove dead code 2026-09-25 11:44:05 -07:00
Alexander Holliday 2947b558a2 Merge pull request #3997 from bluewave-labs/chore/refactor-network-service
chore: refactor network service to remove dead code
2026-09-25 11:41:07 -07:00
Alex Holliday 98a5fa9941 fix names 2026-09-25 11:39:16 -07:00
Alex Holliday 712350d669 refactor network service to remove dead code 2026-09-25 11:34:41 -07:00
Alexander Holliday 1a287be323 Merge pull request #3995 from bluewave-labs/fix/decision-refactor
fix: convert decision objecty into a state object
2026-09-25 11:13:45 -07:00
Alexander Holliday 014750f91e Merge pull request #3996 from bluewave-labs/fix/dashboard-table-state
fix: add a dashboard table state
2026-09-25 11:13:29 -07:00
Alex Holliday 574078bdc8 fix default 2026-09-25 11:11:45 -07:00
Alex Holliday 7ab80ac3f4 add a dashboard table state 2026-09-25 11:06:48 -07:00
Alexander Holliday a258910ccf Merge pull request #3985 from fabiangigler/feat/persist-monitor-sorting
Feat/persist monitor sorting
2026-09-25 10:57:45 -07:00
fabiangigler e15a85bd61 Merge latest develop into monitor sorting
# Conflicts:
#	client/src/Features/UI/uiSlice.ts
2026-09-25 11:31:38 +02:00
fabiangigler ced90211a0 apply monitor sorting feedback 2026-09-25 11:24:32 +02:00
Alex Holliday 4ae8426766 convert decision obj into a state object 2026-09-24 15:13:10 -07:00
Alexander Holliday 32a5ca8799 Merge pull request #3993 from bluewave-labs/fix/extract-formatting-from-incident-service
fix: extract formatting from incident service
2026-09-24 15:08:20 -07:00
Alex Holliday 5d77b8804f update tests 2026-09-24 14:22:31 -07:00
Alex Holliday 544502c6e5 move formatting 2026-09-24 14:19:13 -07:00
Alexander Holliday 1ef540679b Merge pull request #3992 from bluewave-labs/chore/refactor-cleanup
chore: refactor cleanup
2026-09-24 14:10:19 -07:00
Alex Holliday 18f38818e2 update tests 2026-09-24 14:03:34 -07:00
Alex Holliday 02e6333c3c narrow notificaitonReason type 2026-09-24 14:00:54 -07:00
Alex Holliday cb47439e9f remove double logging 2026-09-24 13:55:17 -07:00
Alex Holliday 4bbfe2e889 remove extra write on short window 2026-09-24 13:49:54 -07:00
Alex Holliday 187a0fc3d4 remove extra guard 2026-09-24 13:43:40 -07:00
Alex Holliday 4e4d3c3971 remove code and timestamp from stautschangeresult, no longer used 2026-09-24 13:41:58 -07:00
Alexander Holliday 6267e08a51 Merge pull request #3991 from bluewave-labs/fix/threshold-source-of-truth
fix: threshold source of truth
2026-09-24 13:33:12 -07:00
Alex Holliday f2c23636fe type only imports, add empty guard for threshold breaches do Docker monitors don't get this 2026-09-24 13:20:03 -07:00
Alex Holliday 11c89d150a update tests 2026-09-24 12:58:28 -07:00
Alex Holliday 6b75f9bf7c remove statusChange from decision, no longer needed 2026-09-24 12:41:33 -07:00
Alex Holliday 91de9e7fb7 pass threshold breaches on rather than reconsructing 2026-09-24 12:25:22 -07:00
Alex Holliday 21fb320f3a add hardware metric key types 2026-09-24 11:46:04 -07:00
Alexander Holliday 3586ac9437 Merge pull request #3984 from bluewave-labs/feat/dashboard-add-new-cards-options
feat(dashboard): add currently down and lowest uptime dashboard cards
2026-09-24 09:38:44 -07:00
Malena Aguiar b0ed75382e feat(dashboard): add code refactors 2026-09-23 17:57:28 -06:00
Alexander Holliday b1646cd83b Merge pull request #3988 from bluewave-labs/chore/evaluator-refactor
chore: refactor out rebuilding of status response
2026-09-23 13:56:56 -07:00
Alex Holliday 7abc74ac98 udpate tests 2026-09-23 13:54:55 -07:00
Alex Holliday a1b10c7469 remove unused param 2026-09-23 13:51:30 -07:00
Alex Holliday 0d75629dcb refactor out rebuilding of status response 2026-09-23 13:38:02 -07:00
Alexander Holliday 849ea26cf9 Merge pull request #3987 from bluewave-labs/fix/email-error-handling
fix: improve email error handling
2026-09-23 12:19:39 -07:00
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
fabiangigler 380968472f Allow monitor sort headers to reset to default 2026-09-23 13:35:01 +02:00
fabiangigler 0cb48596a0 Clarify monitor sort fallback 2026-09-23 12:38:57 +02:00
fabiangigler 22d0123966 Reset monitor pages when sorting 2026-09-23 12:37:45 +02:00
fabiangigler 2a45b4d46f Persist monitor list sorting 2026-09-23 12:21:26 +02:00
Malena Aguiar bdd4634a3b feat(dashboard): add currently down and lowest uptime dashboard cards 2026-09-22 16:18:51 -06: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
Malena Aguiar a277a89bef feat(dashboard): add code refactors 2026-09-21 18:29:48 -06:00
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
Malena Aguiar 9d0dc3f330 feat(dashboard): add edit visible cards modal 2026-09-19 14:57:30 -06: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
Alexander Holliday da8b230cec Merge pull request #3957 from Asgabani/fix/temp-breach-boundary-inconsistency
fix(notifications): don't report a temperature breach exactly at the threshold
2026-09-18 09:16:23 -07: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
Alexander Holliday f4864b5455 Merge pull request #3969 from bluewave-labs/fix/dropped-check
fix:  Use Check IDs instead of timestamp to process checks
2026-09-17 12:34:19 -07:00
Alex Holliday f299098548 bug fixes 2026-09-17 12:30:56 -07:00
Alexander Holliday 87eab6f996 Merge pull request #3968 from bluewave-labs/feat/dashboard-add-redux-state
feat: remove mock data and add redux state to dashboard page
2026-09-17 12:08:30 -07:00
Malena Aguiar 31400a2502 remove mock data and add redux state 2026-09-17 12:59:39 -06:00
Alex Holliday fa0c0abcac Fix: Use Check IDs instead of timestamp to process checks 2026-09-17 11:57:19 -07:00
Alexander Holliday 9102335d3e Merge pull request #3967 from bluewave-labs/fix/cache-db-settings
fix: cache DB settings
2026-09-16 11:42:48 -07: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
Ankit Gabani b2ace1bec5 fix(notifications): don't report a temperature breach exactly at the threshold
extractThresholdBreaches used maxTemp >= threshold, while
StatusService.computeHardwareStatus (which drives the actual
"breached" status transition and incident) uses a strict
maxTemp > threshold for the same metric. cpu, memory and disk already
agree on > in both files; temp was the one metric where they diverged.

Fixes #3956.
2026-09-14 01:49:54 +05:30
220 changed files with 6849 additions and 4073 deletions

No files matched your search

+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install client dependencies
working-directory: client
@@ -29,7 +29,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install server dependencies
working-directory: server
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install client dependencies
working-directory: client
@@ -30,7 +30,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install server dependencies
working-directory: server
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
- name: Install server dependencies
working-directory: server
+1 -1
View File
@@ -62,7 +62,7 @@ VITE_APP_LOG_LEVEL="debug"
### Monorepo Structure
- `/client` - React 18 + TypeScript + Vite + MUI frontend
- `/server` - Node.js 20+ + Express + TypeScript backend
- `/server` - Node.js 24+ + Express + TypeScript backend
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
### Backend Layers
+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",
+4
View File
@@ -2,6 +2,10 @@
"name": "client",
"private": true,
"version": "3.12.0",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
+35
View File
@@ -1,8 +1,16 @@
import {
dashboardCardKeys,
type DashboardCardKey,
type DashboardSortOrder,
} from "@/Types/Dashboard";
import type { SortOrder } from "@/Types/Query";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "dashboard"
| "monitors"
| "team"
| "maintenance"
@@ -13,6 +21,8 @@ type TableName =
interface TableState {
rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
}
interface SidebarState {
@@ -20,6 +30,7 @@ interface SidebarState {
}
interface UIState {
dashboard: TableState;
monitors: TableState;
pagespeed: TableState;
team: TableState;
@@ -35,6 +46,8 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardVisibleCards: DashboardCardKey[];
dashboardUptimeSortOrder: DashboardSortOrder;
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -43,6 +56,9 @@ const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark
: "light";
const initialState: UIState = {
dashboard: {
rowsPerPage: 100,
},
monitors: {
rowsPerPage: 10,
},
@@ -74,6 +90,8 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardVisibleCards: [...dashboardCardKeys],
dashboardUptimeSortOrder: "ascending",
};
const uiSlice = createSlice({
@@ -90,6 +108,14 @@ const uiSlice = createSlice({
const { table, value } = action.payload;
state[table].rowsPerPage = value;
},
setTableSort: (
state,
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
) => {
const { table, field, order } = action.payload;
state[table].sortField = field;
state[table].sortOrder = order;
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
@@ -117,6 +143,12 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
state.dashboardVisibleCards = action.payload;
},
setDashboardUptimeSortOrder: (state, action: PayloadAction<DashboardSortOrder>) => {
state.dashboardUptimeSortOrder = action.payload;
},
},
});
@@ -124,6 +156,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
export default uiSlice.reducer;
export const {
setRowsPerPage,
setTableSort,
toggleSidebar,
setCollapsed,
setMode,
@@ -133,4 +166,6 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardVisibleCards,
setDashboardUptimeSortOrder,
} = uiSlice.actions;
+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":
+13 -8
View File
@@ -1,4 +1,4 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
@@ -10,6 +10,7 @@ import {
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
@@ -24,10 +25,12 @@ interface MonitorListConfig {
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
const tableState = useSelector(
(state: RootState) => state.ui?.[config.rowsPerPageTable]
);
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
const sortOrder = tableState?.sortOrder ?? "asc";
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
setPage(0);
};
const setSort = (field: string, order: SortOrder) => {
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
// Delete
selectedMonitor,
+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"}
@@ -0,0 +1,89 @@
import { useMemo, useState } from "react";
import Stack from "@mui/material/Stack";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { Checkbox, Dialog } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
interface EditCardsModalProps {
open: boolean;
visibleCards: Set<DashboardCardKey>;
onClose: () => void;
onSave: (visibleCards: Set<DashboardCardKey>) => void;
}
export const EditCardsModal = ({
open,
visibleCards,
onClose,
onSave,
}: EditCardsModalProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [cardsSelection, setCardsSelection] = useState<Set<DashboardCardKey>>(
new Set(visibleCards)
);
const handleClose = () => {
setCardsSelection(new Set(visibleCards));
onClose();
};
const handleToggle = (key: DashboardCardKey) => {
setCardsSelection((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const sortedCardKeys = useMemo(
() =>
[...dashboardCardKeys].sort((a, b) =>
t(`pages.dashboard.cards.${a}`).localeCompare(t(`pages.dashboard.cards.${b}`))
),
[t]
);
return (
<Dialog
open={open}
title={t("pages.dashboard.editCardsModal.title")}
content={t("pages.dashboard.editCardsModal.description")}
onCancel={handleClose}
onConfirm={() => onSave(cardsSelection)}
confirmText={t("pages.dashboard.editCardsModal.done")}
maxWidth="xs"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XXS)}>
<Typography
variant="eyebrow"
color={theme.palette.text.disabled}
>
{t("pages.dashboard.editCardsModal.groups.uptime")}
</Typography>
{sortedCardKeys.map((key: DashboardCardKey) => (
<FormControlLabel
key={key}
label={t(`pages.dashboard.cards.${key}`)}
control={
<Checkbox
checked={cardsSelection.has(key)}
disabled={cardsSelection.has(key) && cardsSelection.size === 1}
onChange={() => handleToggle(key)}
/>
}
/>
))}
</Stack>
</Dialog>
);
};
@@ -0,0 +1,58 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { Dot } from "@/Components/design-elements";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { Monitor } from "@/Types/Monitor";
interface CurrentlyDownCardProps {
monitors: Monitor[];
}
export const CurrentlyDownCard = ({ monitors }: CurrentlyDownCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const downMonitors = monitors.filter((monitor) => monitor.status === "down");
return (
<DashboardCard
title={t("pages.dashboard.cards.currentlyDown")}
topRight={downMonitors.length > 0 ? downMonitors.length : undefined}
>
{downMonitors.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.currentlyDown.empty")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{downMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.XS)}
>
<Dot color={theme.palette.error.main} />
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{monitor.name}
</Typography>
</Stack>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -9,41 +9,50 @@ import { useTranslation } from "react-i18next";
import type { MonitorsSummary } from "@/Types/Monitor";
interface MonitorStatusCardProps {
summary: MonitorsSummary;
summary: MonitorsSummary | null;
}
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const up = summary.upMonitors;
const total = summary.totalMonitors;
const up = summary?.upMonitors ?? 0;
const total = summary?.totalMonitors ?? 0;
const ratio = total > 0 ? up / total : 0;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
{total === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
)}
</DashboardCard>
);
};
@@ -1,14 +1,16 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
import type { MonitorTypeCount } from "@/Types/Monitor";
import { getMonitorTypeLabel } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface MonitorsByTypeCardProps {
monitorsByType: DashboardTypeCount[];
monitorsByType: MonitorTypeCount[];
}
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
@@ -19,17 +21,26 @@ export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps)
return (
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
{monitorsByType.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,90 @@
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Icon } from "@/Components/design-elements";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { LAYOUT } from "@/Utils/Theme/constants";
import { setDashboardUptimeSortOrder } from "@/Features/UI/uiSlice";
import type { Monitor } from "@/Types/Monitor";
import type { AppDispatch, RootState } from "@/store";
const TOP_N = 5;
interface UptimeCardProps {
monitors: Monitor[];
}
export const UptimeCard = ({ monitors }: UptimeCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const sortOrder = useSelector((state: RootState) => state.ui.dashboardUptimeSortOrder);
const direction = sortOrder === "ascending" ? 1 : -1;
const ranked = monitors
.filter((monitor) => monitor.status !== "paused")
.filter(
(monitor): monitor is Monitor & { uptimePercentage: number } =>
typeof monitor.uptimePercentage === "number"
)
.sort((a, b) => direction * (a.uptimePercentage - b.uptimePercentage))
.slice(0, TOP_N);
return (
<DashboardCard
title={t("pages.dashboard.cards.uptime")}
topRight={
<IconButton
size="small"
aria-label={t("pages.dashboard.uptimeCard.toggleSortOrder")}
onClick={() =>
dispatch(
setDashboardUptimeSortOrder(
sortOrder === "ascending" ? "descending" : "ascending"
)
)
}
>
<Icon
icon={sortOrder === "ascending" ? ArrowUp : ArrowDown}
size={16}
color={theme.palette.text.secondary}
/>
</IconButton>
}
>
{ranked.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{ranked.map((monitor) => {
const paletteKey = getUptimePercentageColor(monitor.uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<BarRow
key={monitor.id}
label={monitor.name}
value={formatPercentage(monitor.uptimePercentage)}
fillRatio={monitor.uptimePercentage}
valueColor={color}
/>
);
})}
</Stack>
)}
</DashboardCard>
);
};
+81 -26
View File
@@ -1,43 +1,98 @@
import { Fragment, useMemo, useState, type ReactNode } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { BasePage } from "@/Components/design-elements/BasePage";
import { Button } from "@/Components/inputs";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { MonitorTypes, type MonitorTypeCount } from "@/Types/Monitor";
import { setDashboardVisibleCards } from "@/Features/UI/uiSlice";
import type { RootState, AppDispatch } from "@/store";
import { MonitorStatusCard } from "@/Pages/Dashboard/components/cards/MonitorStatusCard";
import { CurrentlyDownCard } from "@/Pages/Dashboard/components/cards/CurrentlyDownCard";
import { UptimeCard } from "@/Pages/Dashboard/components/cards/UptimeCard";
import { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import type { MonitorsSummary } from "@/Types/Monitor";
import type { DashboardTypeCount } from "@/Pages/Dashboard/types";
const MOCK_SUMMARY: MonitorsSummary = {
totalMonitors: 12,
upMonitors: 10,
downMonitors: 1,
pausedMonitors: 1,
initializingMonitors: 0,
maintenanceMonitors: 0,
breachedMonitors: 0,
};
const MOCK_MONITORS_BY_TYPE: DashboardTypeCount[] = [
{ type: "http", count: 7 },
{ type: "ping", count: 3 },
{ type: "port", count: 2 },
];
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const [editOpen, setEditOpen] = useState(false);
const visibleCardsList = useSelector(
(state: RootState) => state.ui.dashboardVisibleCards
);
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
const { monitors, summary } = useMonitorListController({
types: [...MonitorTypes],
checksLimit: 1,
refreshInterval: 30000,
rowsPerPageTable: "dashboard",
rowsPerPageDefault: 100,
});
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
const countMap = new Map<MonitorTypeCount["type"], number>();
(monitors ?? []).forEach((m) =>
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
);
return [...countMap.entries()]
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
}, [monitors]);
const cardComponents: Record<DashboardCardKey, ReactNode> = {
monitorStatus: <MonitorStatusCard summary={summary} />,
currentlyDown: <CurrentlyDownCard monitors={monitors ?? []} />,
uptime: <UptimeCard monitors={monitors ?? []} />,
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
};
return (
<BasePage headerKey="dashboard">
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
<MonitorStatusCard summary={MOCK_SUMMARY} />
<MonitorsByTypeCard monitorsByType={MOCK_MONITORS_BY_TYPE} />
</Box>
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="outlined"
size="small"
onClick={() => setEditOpen(true)}
>
{t("pages.dashboard.editCards")}
</Button>
</Stack>
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
{dashboardCardKeys
.filter((key) => visibleCards.has(key))
.map((key) => (
<Fragment key={key}>{cardComponents[key]}</Fragment>
))}
</Box>
</Stack>
<EditCardsModal
open={editOpen}
visibleCards={visibleCards}
onClose={() => setEditOpen(false)}
onSave={(next) => {
dispatch(setDashboardVisibleCards([...next]));
setEditOpen(false);
}}
/>
</BasePage>
);
};
-6
View File
@@ -1,6 +0,0 @@
import type { MonitorType } from "@/Types/Monitor";
export interface DashboardTypeCount {
type: MonitorType;
count: number;
}
@@ -10,6 +10,7 @@ import type { DockerContainerInfo } from "@/Types/Check";
// Utils
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>;
},
},
];
@@ -8,6 +8,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs";
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
+2 -2
View File
@@ -19,6 +19,7 @@ const DockerMonitorsPage = () => {
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<DockerMonitorsTable
monitors={c.monitors || []}
@@ -30,9 +31,8 @@ const DockerMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -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();
@@ -9,6 +9,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
/>
</MonitorListPage>
@@ -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>;
};
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps {
@@ -34,9 +35,8 @@ interface MonitorTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,9 +52,8 @@ export const MonitorTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -91,14 +90,8 @@ export const MonitorTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const isBrowserOpenable = (monitor: Monitor): boolean => {
+1 -2
View File
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder}
setSortOrder={monitorListController.setSortOrder}
setSort={monitorListController.setSort}
setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
+10
View File
@@ -0,0 +1,10 @@
export const dashboardCardKeys = [
"monitorStatus",
"currentlyDown",
"uptime",
"monitorsByType",
] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
export const dashboardSortOrders = ["ascending", "descending"] as const;
export type DashboardSortOrder = (typeof dashboardSortOrders)[number];
+7
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;
@@ -192,6 +194,11 @@ export interface MonitorsSummary {
breachedMonitors: number;
}
export interface MonitorTypeCount {
type: MonitorType;
count: number;
}
export interface MonitorsWithChecksResponse {
count: number;
monitors: MonitorWithChecks[];
+3
View File
@@ -1,2 +1,5 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
export const SortOrders = ["asc", "desc"] as const;
export type SortOrder = (typeof SortOrders)[number];
+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 = "—";
+12 -1
View File
@@ -1,4 +1,5 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { SortOrder } from "@/Types/Query";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import {
@@ -10,6 +11,16 @@ import {
type DockerLogLine,
} from "@/Types/Check";
export const getNextMonitorSort = (
field: string,
sortField: string,
sortOrder: SortOrder
): { field: string; order: SortOrder } => {
if (field !== sortField) return { field, order: "asc" };
if (sortOrder === "asc") return { field, order: "desc" };
return { field: "", order: "asc" };
};
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
http: "uptime",
@@ -20,7 +31,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": {
+43 -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",
@@ -1911,16 +1930,34 @@
}
},
"dashboard": {
"noData": "No data available at this moment.",
"monitorStatus": {
"ofUp": "of {{total}} up"
},
"cards": {
"monitorStatus": "Monitor status",
"currentlyDown": "Currently down",
"uptime": "Monitor uptime",
"monitorsByType": "Monitors by type"
},
"uptimeCard": {
"toggleSortOrder": "Toggle sort order"
},
"currentlyDown": {
"empty": "No monitors are down at this moment."
},
"header": {
"title": "Dashboard",
"description": "See every monitor type in one place and spot what needs attention."
},
"editCards": "Edit cards",
"editCardsModal": {
"title": "Dashboard cards",
"description": "Select the cards to display on your dashboard.",
"groups": {
"uptime": "Uptime"
},
"done": "Done"
}
}
}
+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": {
+2 -2
View File
@@ -4,7 +4,7 @@
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
# pure JS so it's arch-independent, and this avoids the esbuild host/native
# binary version mismatch under emulation.
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
FROM --platform=$BUILDPLATFORM node:24-slim AS frontend-build
WORKDIR /app/client
@@ -19,7 +19,7 @@ RUN npm run build
# ---------------------
# Backend stage
# ---------------------
FROM node:22-slim AS app
FROM node:24-slim AS app
# Install ping
RUN apt-get update \
+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": {
+493 -445
View File
File diff suppressed because it is too large. Load diff
+8 -6
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 .",
@@ -30,7 +30,8 @@
"author": "",
"license": "ISC",
"engines": {
"node": ">=20"
"node": ">=24",
"npm": ">=11"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
@@ -42,9 +43,10 @@
"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",
"express": "5.2.1",
"express-rate-limit": "^8.5.1",
"gamedig": "5.2.0",
"got": "14.4.7",
@@ -57,11 +59,11 @@
"jsonwebtoken": "9.0.2",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.2",
"nodemailer": "9.0.5",
"multer": "2.4.0",
"nodemailer": "9.1.1",
"ping": "0.4.4",
"re2": "^1.24.1",
"sharp": "0.35.3",
"sharp": "0.35.4",
"ssl-checker": "2.0.10",
"swagger-ui-express": "5.0.1",
"tldts": "^7.4.10",
+1 -64
View File
@@ -1,5 +1,5 @@
import { AppError } from "@/utils/AppError.js";
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
import { Monitor } from "@/domain/monitors/monitor.type.js";
import { UserRole } from "@/domain/users/user.type.js";
import sslChecker, { SSLDetails } from "ssl-checker";
import * as whoiser from "whoiser";
@@ -124,23 +124,6 @@ export const fetchMonitorDomain = async (
return domainExpiry;
};
export const requireString = (value: unknown, fieldName: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
throw new AppError({ message: `${fieldName} is required`, status: 400 });
};
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "string") {
return value;
}
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const extractString = (value: unknown): string | undefined => {
const candidate = Array.isArray(value) ? value[0] : value;
if (typeof candidate !== "string" || candidate.length === 0) {
@@ -149,52 +132,6 @@ export const extractString = (value: unknown): string | undefined => {
return candidate;
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
throw new AppError({ message: "Monitor type must be a string", status: 400 });
}
if (!MonitorTypes.includes(input as MonitorType)) {
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
}
return input as MonitorType;
};
if (value === undefined) {
return undefined;
}
if (Array.isArray(value)) {
return value.map((entry) => parseSingle(entry));
}
return parseSingle(value);
};
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
if (value === undefined) {
return undefined;
}
if (value === "asc" || value === "desc") {
return value;
}
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
};
export const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
@@ -35,11 +35,18 @@ class NotificationController implements INotificationController {
testNotification = catchAsync(async (req: Request, res: Response) => {
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);
+4 -8
View File
@@ -53,14 +53,10 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
return (req: Request, res: Response, next: NextFunction) => {
if (req.query && typeof req.query === "object") {
for (const key of Object.keys(req.query)) {
const value = req.query[key];
if (typeof value === "string") {
req.query[key] = sanitizeInput(value, options);
}
}
}
const sanitized = Object.fromEntries(
Object.entries(req.query).map(([key, value]) => [key, typeof value === "string" ? sanitizeInput(value, options) : value])
);
Object.defineProperty(req, "query", { value: sanitized, writable: true, configurable: true, enumerable: true });
next();
};
};
@@ -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
@@ -122,7 +122,7 @@ export const createApp = ({
setupRoutes(app, controllers, services);
// FE routes
app.get("*", (req, res) => {
app.get("/{*splat}", (req, res) => {
res.sendFile(path.join(frontendPath, "index.html"));
});
app.use(handleErrors(services.logger));
+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);
+45 -48
View File
@@ -1,5 +1,4 @@
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
import Docker from "dockerode";
@@ -13,38 +12,36 @@ import mongoose from "mongoose";
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IProviderRegistry, ProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { 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";
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
import { ProxyResolver } from "@/service/networkProviders/ProxyResolver.js";
import { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/network/HardwareProvider.js";
import { DockerProvider } from "@/service/network/DockerProvider.js";
import { PortProvider } from "@/service/network/PortProvider.js";
import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
import { PingProvider } from "@/service/networkProviders/PingProvider.js";
import { HttpProvider } from "@/service/networkProviders/HttpProvider.js";
import { AdvancedMatcher } from "@/service/networkProviders/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/networkProviders/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/networkProviders/HardwareProvider.js";
import { DockerProvider } from "@/service/networkProviders/DockerProvider.js";
import { PortProvider } from "@/service/networkProviders/PortProvider.js";
import { GameProvider } from "@/service/networkProviders/GameProvider.js";
import { GrpcProvider } from "@/service/networkProviders/GrpcProvider.js";
import { WebSocketProvider } from "@/service/networkProviders/WebSocketProvider.js";
import { DNSProvider } from "@/service/networkProviders/DNSProvider.js";
import { AppError } from "@/utils/AppError.js";
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
networkService: IProviderRegistry;
bufferService: IBufferService;
statusService: IStatusService;
egressService: IEgressService;
@@ -82,14 +79,14 @@ 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);
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
const providerRegistry = new ProviderRegistry([
pingProvider,
httpProvider,
pageSpeedProvider,
@@ -103,36 +100,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 egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, providerRegistry, proxyResolver, logger);
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,
providerRegistry: providerRegistry,
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 +147,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,
@@ -190,5 +187,5 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
});
}
return { worker, networkService, bufferService, statusService, egressService };
return { worker, networkService: providerRegistry, bufferService, statusService, egressService };
};
@@ -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;
@@ -9,6 +9,7 @@ import type {
PageSpeedChecksResult,
UptimeChecksResult,
} from "@/domain/checks/check.type.js";
import { PendingCheck } from "@/domain/jobs/job.type.js";
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
import { CheckFilter, DateRange } from "@/types/query.js";
@@ -43,7 +44,7 @@ export interface IChecksRepository {
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise<Omit<DockerContainerStats, "restartsInRange">>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
findUnevaluatedByMonitorId(monitorId: string, pending: PendingCheck[]): Promise<Check[]>;
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
// update
@@ -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";
@@ -33,6 +34,7 @@ import {
getDockerTotalChecks,
getDockerUpChecks,
} from "@/domain/checks/check.docker.aggregation.js";
import { PendingCheck } from "@/domain/jobs/job.type.js";
const SERVICE_NAME = "ChecksRepository";
@@ -178,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),
};
@@ -347,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([
@@ -360,10 +364,23 @@ class MongoChecksRepository implements IChecksRepository {
};
};
findUnevaluatedByMonitorId = async (monitorId: string, since: number) => {
findUnevaluatedByMonitorId = async (monitorId: string, pending: PendingCheck[]) => {
if (pending.length === 0) return [];
const ids = pending.map((entry) => new mongoose.Types.ObjectId(entry.checkId));
// Bound the query to the pending time range so the monitorId + createdAt index is used
let from = Number.POSITIVE_INFINITY;
let to = Number.NEGATIVE_INFINITY;
for (const entry of pending) {
if (entry.createdAt < from) from = entry.createdAt;
if (entry.createdAt > to) to = entry.createdAt;
}
const docs = await CheckModel.find({
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
createdAt: { $gt: new Date(since) },
createdAt: { $gte: new Date(from), $lte: new Date(to) },
_id: { $in: ids },
})
.sort({ createdAt: 1 })
.lean<CheckDocument[]>();
@@ -379,6 +396,7 @@ class MongoChecksRepository implements IChecksRepository {
$match: {
"metadata.monitorId": { $in: objectIds },
createdAt: { $gte: windowStart },
...EXCLUDE_DEGRADED_EGRESS_MATCH,
},
},
{
@@ -459,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 },
@@ -624,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([
-24
View File
@@ -12,9 +12,7 @@ const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
toLastEvaluatedAt(check: Check): number;
toCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
toStatusResponse(check: Check): MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>;
getChecksByMonitor(params: {
monitorId: string;
teamId: string;
@@ -55,10 +53,6 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
toLastEvaluatedAt = (check: Check) => {
return new Date(check.createdAt).getTime();
};
toCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
@@ -140,24 +134,6 @@ export class CheckService implements ICheckService {
return check;
};
toStatusResponse = (check: Check) => {
const statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]> = {
monitorId: check.metadata.monitorId,
teamId: check.metadata.teamId,
type: check.metadata.type,
status: check.status,
code: check.statusCode,
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,
};
return statusResponse;
};
getChecksByMonitor = async ({
monitorId,
teamId,
+8 -6
View File
@@ -45,12 +45,14 @@ const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined =>
};
const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined =>
disk?.map((entry) => ({
device: entry.device,
total_bytes: entry.total_bytes,
used_bytes: entry.used_bytes,
usage_percent: entry.usage_percent,
}));
disk
?.filter((entry) => entry != null)
.map((entry) => ({
device: entry.device,
total_bytes: entry.total_bytes,
used_bytes: entry.used_bytes,
usage_percent: entry.usage_percent,
}));
const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined =>
host && {
+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 {
+11 -16
View File
@@ -3,17 +3,17 @@ import type { ISettingsService } from "@/domain/app-settings/app-settings.servic
import type { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { jobId, type Job, type JobSeed } from "@/domain/jobs/job.type.js";
import type { INetworkService } from "@/service/networkService.js";
import type { IProxyResolver } from "@/service/network/ProxyResolver.js";
import type { IProviderRegistry } from "@/service/networkProviders/providerRegistry.js";
import type { IProxyResolver } from "@/service/networkProviders/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,
type EgressStatus,
} from "@/domain/egress/egress.type.js";
import { timeRequest } from "@/service/network/utils.js";
import { timeRequest } from "@/service/networkProviders/utils.js";
import { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
const SERVICE_NAME = "EgressService";
@@ -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";
@@ -54,7 +54,7 @@ export class EgressService implements IEgressService {
private settingsService: ISettingsService,
private egressStateRepository: IEgressStateRepository,
private jobsRepository: IJobsRepository,
private networkService: INetworkService,
private providerRegistry: IProviderRegistry,
private proxyResolver: IProxyResolver,
private logger: ILogger
) {}
@@ -100,7 +100,7 @@ export class EgressService implements IEgressService {
const { response, responseTime, error } = await timeRequest(async () => {
const proxyUrl = await this.proxyResolver.resolve(monitor);
return Promise.race([
this.networkService.requestStatus(monitor, { proxyUrl }),
this.providerRegistry.probe(monitor, { proxyUrl }),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS);
}),
@@ -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>;
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more