Compare commits

...
Author SHA1 Message Date
Malena Aguiar 10e127d0db fix: resolve ssl certificate port issue 2026-09-22 16:27:20 -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 011b93ecc1 update tests 2026-09-16 11:40:42 -07:00
Alex Holliday 20ce11497e revert cleanup job to uncached settings 2026-09-16 11:40:14 -07:00
Alex Holliday 50d0721822 cache DB settings, used cached db settings in ProxyResolver, PageSpeedProvider, and EgressService 2026-09-16 11:37:29 -07:00
Alexander Holliday c66872c7f4 Merge pull request #3966 from bluewave-labs/chore/remove-easy-panel-screenshot
chore: remove easypanel screenshot
2026-09-15 20:13:42 -07:00
Alex Holliday b1be38368c remove screenshot 2026-09-15 20:11:10 -07:00
Alexander Holliday 278f652ea9 Merge pull request #3944 from bluewave-labs/feat/refined-dashboard
feat: create dashboard of updates
2026-09-15 13:40:17 -07:00
Malena Aguiar d3e12722b7 add code adjustments 2026-09-15 12:42:57 -06:00
Malena Aguiar 8c9ff41074 fix: resolve conflicts 2026-09-15 11:40:29 -06:00
Malena Aguiar 71a1194373 fix: add code adjustments 2026-09-15 11:29:08 -06:00
Alexander Holliday 580a025353 Merge pull request #3962 from bluewave-labs/fix/reset-job-state-on-success
fix: reset job state on success
2026-09-15 10:18:28 -07:00
Alexander Holliday 33994cb3df Merge pull request #3961 from Ahson-Shaikh/add-easypanel-deploy-docs
Add Easypanel to 1-click installation options
2026-09-15 10:18:08 -07:00
Alex Holliday 44edf320a0 reset job state on success 2026-09-15 10:13:20 -07:00
Alexander Holliday fea2ef4dbe Merge pull request #3959 from web-engineer/feat/egress-1-check-service
Egress self-check (1/4): egress state and probe service (#3937)
2026-09-15 09:51:01 -07:00
Ahson Shaikh 360b4b8035 Add Easypanel deployment screenshot 2026-09-15 19:11:38 +05:00
Ahson Shaikh 3857a71e62 Add Easypanel to 1-click installation options 2026-09-15 16:58:35 +05:00
Craig Lawson b68367b04f Harden the egress recovery job after a second review pass (#3937)
- Schedule the recovery job before recording the degraded transition,
  so a crash between the two leaves a stray row (removed on the job's
  first run) rather than a degraded state nothing is polling
- Count any HTTP response, including 4xx/5xx, as reachable for http
  targets; only a transport failure counts against egress
- Cascade a deleted notification out of egressNotifications with an
  atomic $pull in the settings repository, after the team-scoped delete
- Handle two processes racing the first egress state insert
- Address global job rows by one key (deleteGlobalJob on _id) and
  restore the deleteByIdAndType signature
- Inject net into PingProvider like PortProvider
- Integration tests for MongoEgressStateRepository and the global-row
  delete methods on the jobs repository

🔧
2026-09-15 08:59:13 +01:00
Craig Lawson 08f104e283 Address review on the egress recovery job (#3937)
- The egress job runs through WorkerHelper.getEgressRecoveryJob(), like
  the cleanup jobs, instead of a direct service call from the queue
- Toggling egressCheckEnabled resets the egress state and removes any
  pending recovery job
- Probes resolve and pass the proxy, so http targets honour the global
  proxy as monitors do
- PingProvider keeps IPv6 addresses intact instead of truncating at the
  first colon
- Egress targets are parsed by one function shared by the validator and
  the probe; malformed addresses such as unbalanced brackets are rejected
- Notification id pattern drops the /i flag, which produced an invalid
  OpenAPI pattern
- EgressState findSingleton reads before it writes, so a read no longer
  bumps updatedAt
- "disabled" is a distinct assessment result and is cached like any other
- Concurrent failures share an in-flight assessment for as long as it
  runs, then reuse the settled result for 5 s
- The recovery job row is only removed if nobody rescheduled it since
  this run claimed it, so a concurrent new episode keeps its job
- Deleting a notification removes it from egressNotifications

🔧
2026-09-15 08:42:09 +01:00
Craig Lawson 46eb0b4063 Replace the egress recovery timer with a scheduled job (#3937)
A failed check still calls assessAfterFailure, which now shares one
in-flight assessment between concurrent failures (same cache shape as
ProxyResolver). When egress degrades it upserts a global "egress" job;
DBQueueWorker runs checkRecovery for that type at the configured
interval, and the job deletes its own row once any target answers.
The unref'd timer, startRecoveryPolling, stop and init are gone: a
degraded episode survives a restart through the jobs collection.
deleteByIdAndType accepts a null refId for global rows.

🔧
2026-09-14 20:41:58 +01:00
Alex Holliday 2632f98ad0 Add scrollbar with thumb and tack options to the palette. Add scrollbar styles to MUI theme 2026-09-14 10:12:18 -07:00
Alexander Holliday 84539f76cf Merge pull request #3913 from fabiangigler/feat/localized-monitor-times
Localize formatDuration output
2026-09-14 09:49:15 -07:00
Alex Holliday 308672d558 pass locale to formatting 2026-09-14 09:45:24 -07:00
Alex Holliday 0f474d82d6 use exact unit for duration 2026-09-14 09:45:09 -07:00
Alex Holliday 650cfe00ce Merge branch 'develop' into feat/localized-monitor-times 2026-09-14 09:33:47 -07:00
Alexander Holliday 69eb9957c6 Merge pull request #3881 from fabiangigler/feat/custom-monitor-intervals
Add custom monitor interval options
2026-09-14 09:29:15 -07:00
Craig Lawson ec01a5dfe4 Add egress state and probe service (#3937)
Adds the EgressService that probes a configurable set of reliability targets
(ping, TCP or HTTP) when asked, persists a single instance-wide egress state
document, and runs a recovery poll loop while egress is degraded. Adds the four
egress settings fields with validation. The worker constructs and initialises
the service but nothing calls assessAfterFailure yet, so behaviour is unchanged.

🔧
2026-09-14 10:34:12 +01: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
fabiangigler 5da472da14 Prevent clearing monitor intervals 2026-09-13 20:28:27 +02:00
fabiangigler 4bb366f5ab Add custom monitor interval options 2026-09-13 20:28:27 +02:00
Malena Aguiar cc4b8b38f5 feat: leave only ui changes 2026-09-12 16:17:16 -06:00
Alexander Holliday f41e0f0619 Merge pull request #3949 from kah-ja/fix/register-invite-token-validation
validate invite token type on registration
2026-09-12 15:02:48 -07:00
Alexander Holliday 3f10b6e567 Merge pull request #3952 from bluewave-labs/chore/release-prep
chore: bump minor version, fix unused import
2026-09-12 14:57:45 -07:00
Alex Holliday 9ad20daa51 bump minor version, fix unused import 2026-09-12 14:56:07 -07:00
Malena Aguiar 86b59da909 feat: clean code 2026-09-12 15:53:21 -06:00
Alexander Holliday 6a9068a151 Merge pull request #3951 from bluewave-labs/fix/svg-optimization
fix: reoptimize svg
2026-09-12 14:52:01 -07:00
Alex Holliday b31ffa5318 reoptimize svg 2026-09-12 14:46:05 -07:00
Malena Aguiar 71dd998390 feat: simplify code and functionalities 2026-09-12 15:41:03 -06:00
Alexander Holliday 102a9f1484 Merge pull request #3950 from bluewave-labs/chore/readme
chore: update readme
2026-09-12 14:17:01 -07:00
Alex Holliday 581ec9382b docker monitor instructions 2026-09-12 14:16:14 -07:00
Alex Holliday cebe9350f7 update readme 2026-09-12 14:07:31 -07:00
Jan Kahmen 406518e0a5 validate invite token type on registration 2026-09-12 12:08:33 +00:00
Alexander Holliday c458d5fb65 Merge pull request #3948 from bluewave-labs/feat/client-side-docker-tls
feat: client side docker tls
2026-09-11 12:10:28 -07:00
Alex Holliday ba0d685c6d commetns 2026-09-11 12:07:45 -07:00
Alex Holliday 912f1f960d add missing timeout 2026-09-11 11:50:26 -07:00
Alex Holliday 1de0edf7e3 Merge branch 'develop' into feat/client-side-docker-tls 2026-09-11 11:37:11 -07:00
Alex Holliday 8c9cb4be77 update create monitor form 2026-09-11 11:23:11 -07:00
Alex Holliday 727b904ca9 add docker TLS to monitor type 2026-09-11 11:21:39 -07:00
Alex Holliday 5c0279a6a7 add dockerTLS to form 2026-09-11 11:21:13 -07:00
Alex Holliday 9d72d20dd2 docker TLS validation 2026-09-11 11:20:47 -07:00
Alexander Holliday 62d0630702 Merge pull request #3947 from bluewave-labs/feat/validate-and-encrypt-docker-tls-key
feat: add validation for docker TLS fields, encrypt TLS key on create/edit monitor
2026-09-11 11:13:52 -07:00
Alex Holliday 2260b4a4d2 add validation for docker TLS fields, encrypt TLS key on create/edit monitor 2026-09-11 11:10:37 -07:00
Alexander Holliday cfb50afe42 Merge pull request #3946 from bluewave-labs/feat/secret-rotation
feat: secret rotation
2026-09-11 10:13:58 -07:00
Alex Holliday b14b2142e3 add secrets rotation service and tests 2026-09-11 10:09:30 -07:00
Malena Aguiar 896d73f12e feat: create dashboard of updates 2026-09-10 18:00:12 -06:00
Alex Holliday c64c4cc5ff update tests 2026-09-10 12:26:25 -07:00
Alex Holliday 7071de294f add docker TLS fields to toEntity 2026-09-10 12:26:12 -07:00
Alex Holliday 5eb16fdbdb add validation, helpers, tests 2026-09-10 12:23:20 -07:00
Alex Holliday 131fd1a6b0 update tests 2026-09-10 11:58:26 -07:00
Alex Holliday f9bc02ed1c update msgs 2026-09-10 11:58:12 -07:00
Alex Holliday 1035e397e0 inject encryptionService into DockerProvider 2026-09-10 11:57:43 -07:00
Alex Holliday 9c788561a7 update docker provider to add a TLS path 2026-09-10 11:49:53 -07:00
Alex Holliday 449e96d842 add dockerTlsKey to context for docker type monitors 2026-09-10 11:30:26 -07:00
Alex Holliday c7626c669a Add docker tls key to check context 2026-09-10 11:29:41 -07:00
Alex Holliday ede5bc4ecd add docker TLS fields to type, model, access methods to repo 2026-09-10 11:15:20 -07:00
Alexander Holliday 8bb60bfd03 Merge pull request #3938 from bluewave-labs/feat/encryption-module
feat: encryption module
2026-09-10 10:43:02 -07:00
Alex Holliday f009aa17d9 Add encryption service and tests. Update README 2026-09-10 10:31:41 -07:00
Alexander Holliday ef7ac8bef8 Merge pull request #3936 from bluewave-labs/feat/docker-chart-tooltips
feat: add docker chart tooltips
2026-09-09 10:26:15 -07:00
Alex Holliday b75f895a1f add docker chart tooltips 2026-09-09 10:22:02 -07:00
Alexander Holliday 53f5b61e8e Merge pull request #3935 from bluewave-labs/fix/404-illustration-credit
fix(client): credit Annie Hsu on the 404 illustration
2026-09-09 09:37:30 -07:00
Alex Holliday 64a2c69540 convert styles 2026-09-09 09:35:20 -07:00
Alex Holliday c4ec14db5d add link to SVG 2026-09-09 09:28:40 -07:00
gorkem-bwl 4560820649 fix(client): balance the 404 credit colour across both themes
The signature was #9a9a9a, picked against the dark ground. The app
switches the whole tree between lightTheme and darkTheme at App.tsx:25
and the 404 page sits inside that, so it also renders on the light
background - where #9a9a9a manages only 2.64:1 and reads as washed out.

#787878 is the balance point: 4.13:1 on the dark ground (#151518) and
4.14:1 on the light one (#F6F8F7), chosen by maximising the worse of
the two rather than tuning for either. Checked by rendering on both.
2026-09-08 19:31:22 -04:00
gorkem-bwl 3051bc7edc fix(client): credit Annie Hsu on the 404 illustration
The sushi artwork on the 404 page was designed by Annie Hsu and carried
no attribution. Adds her name in the bottom-right corner, in a script
face at a size that reads as a signature rather than a label.

The glyphs are embedded as vector outlines rather than a `<text>`
element. The asset is loaded through an `<img>` tag in
Pages/NotFound/index.tsx, and an img-loaded SVG cannot fetch external
resources, so a web font would silently fall back to whatever cursive
face the viewer happens to have - different on every platform. Outlines
render identically everywhere and stay sharp at any size, which a raster
export would not.

Adds ~21KB to the asset.
2026-09-08 19:23:44 -04:00
fabiangigler c9b99eabb4 Localize monitor timing details 2026-09-02 20:15:39 +02:00
197 changed files with 8889 additions and 2909 deletions

No files matched your search

+126 -8
View File
@@ -92,7 +92,7 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
### Configuration
@@ -104,7 +104,15 @@ The image is configured entirely through environment variables on the server con
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
| `PORT` | No | Port the API and web client are served on (default `52345`) |
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
@@ -118,12 +126,122 @@ The web client needs no configuration by default: it calls the API on the same o
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
### Docker monitors
A Docker monitor 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 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:
```yaml
services:
checkmate:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "989" # the gid printed by stat
```
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
### Securing the Docker daemon with TLS
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
```bash
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
```
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
```bash
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
cat > server-ext.cnf <<EOF
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
```
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
```bash
openssl genrsa -out key.pem 4096
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
rm client.csr server.csr server-ext.cnf client-ext.cnf
chmod 0400 ca-key.pem key.pem server-key.pem
chmod 0444 ca.pem server-cert.pem cert.pem
```
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
```json
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/certs/ca.pem",
"tlscert": "/etc/docker/certs/server-cert.pem",
"tlskey": "/etc/docker/certs/server-key.pem"
}
```
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
```bash
sudo systemctl edit docker.service
```
```ini
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
```
```bash
sudo systemctl daemon-reload && sudo systemctl restart docker
```
Open port `2376` on the host firewall only to the machine running Checkmate.
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
```bash
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
-H=docker.example.com:2376 version
```
For more documentation, see the [docs directory](./docs/).
<a id="performance"></a>
@@ -133,7 +251,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9)
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
@@ -151,16 +269,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection
- Incidents at a glance
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Status pages with 5 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
## Monitor Lifecycle
@@ -213,7 +331,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K stars and attracted 150+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
+4 -24
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.11.0",
"version": "3.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.12.0",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
@@ -93,7 +93,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -409,7 +408,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -447,7 +445,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1236,7 +1233,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1341,7 +1337,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2386,7 +2381,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2402,7 +2396,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2493,7 +2486,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2774,7 +2766,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3131,7 +3122,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3575,8 +3565,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3975,7 +3964,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4788,7 +4776,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5600,7 +5587,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6382,7 +6368,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6394,7 +6379,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6598,8 +6582,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6749,7 +6732,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7591,7 +7573,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7729,7 +7710,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.12.0",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
@@ -8,8 +8,11 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const { t, i18n } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!monitor) {
@@ -52,7 +55,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<>
<Dot />
<Typography>
Checking every {formatDuration(monitor?.interval, true)}
{t("pages.common.monitors.statBoxes.checkingEvery", {
interval: formatDuration(monitor.interval, {
long: true,
locale: i18n.resolvedLanguage,
}),
})}
</Typography>
</>
)}
@@ -5,9 +5,10 @@ import {
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
export interface AutocompleteOption {
id: string;
id: string | number;
name: string;
}
@@ -16,6 +17,9 @@ interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOpt
options: O[];
fieldLabel?: string;
placeholder?: string;
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
disableClearable?: boolean;
}
export const FormAutocompleteField = <
@@ -26,6 +30,9 @@ export const FormAutocompleteField = <
options,
fieldLabel,
placeholder,
filterOptions,
getOptionDisabled,
disableClearable,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
@@ -39,10 +46,14 @@ export const FormAutocompleteField = <
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
if (newValue === null && disableClearable) return;
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
filterOptions={filterOptions}
getOptionDisabled={getOptionDisabled}
disableClearable={disableClearable}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
@@ -21,7 +21,7 @@ export const MonitorStatBoxes = ({
domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
if (!monitorStats || !monitor) {
return null;
}
@@ -29,13 +29,16 @@ export const MonitorStatBoxes = ({
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure);
const lastCheckTime = formatDuration(timeSinceLastCheck);
const lastCheckTime = timeOfLastCheck
? formatDuration(Date.now() - timeOfLastCheck, {
long: true,
relative: true,
locale: i18n.resolvedLanguage,
})
: t("common.labels.na");
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
@@ -1,15 +1,58 @@
import { BaseChart } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts";
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import Typography from "@mui/material/Typography";
// Types
import type { DockerContainerStats } from "@/Types/Monitor";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import type { Theme } from "@mui/material/styles";
import type { RootState } from "@/Types/state";
// Hooks
import { useTheme } from "@mui/material";
import { Fragment, useId } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
// Utils
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
import type { DateRange } from "@/Types/Query";
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
dataKey: string;
range: string;
theme: Theme;
uiTimezone: string;
valueFormatter: (value: number) => string;
labelKey: string;
};
const DockerHistogramToolTip = ({
dataKey,
active,
payload,
range,
theme,
uiTimezone,
valueFormatter,
labelKey,
}: DockerHistogramToolTipProps) => {
const { t } = useTranslation();
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const ts = payload[0].payload?._id ?? null;
const dataPoint = payload[0].payload?.[dataKey] ?? null;
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
</BaseBox>
);
};
interface HistogramDockerContainerProps {
title: string;
rightTitle: string;
@@ -17,6 +60,9 @@ interface HistogramDockerContainerProps {
dataKey: string;
strokeColor: string;
gradientStartColor: string;
range: DateRange;
formatter: (value: number) => string;
labelKey: string;
}
export const HistogramDockerContainer = ({
@@ -26,11 +72,14 @@ export const HistogramDockerContainer = ({
dataKey,
strokeColor,
gradientStartColor,
range,
formatter,
labelKey,
}: HistogramDockerContainerProps) => {
const uniqueId = useId();
const theme = useTheme();
const gradientId = `gradient-${uniqueId}`;
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon="null"
@@ -56,6 +105,19 @@ export const HistogramDockerContainer = ({
endColor: "transparent",
direction: "vertical",
})}
<Tooltip
content={(props) => (
<DockerHistogramToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
dataKey={dataKey}
valueFormatter={formatter}
labelKey={labelKey}
/>
)}
/>
<Area
dataKey={dataKey}
type="monotone"
+6
View File
@@ -20,12 +20,18 @@ import {
Tag,
Waypoints,
Container,
// LayoutDashboard,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
group: t("components.sidebar.menu.groups.monitoring"),
items: [
// {
// name: t("components.sidebar.menu.dashboard"),
// path: "dashboard",
// icon: <Icon icon={LayoutDashboard} />,
// },
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
+8
View File
@@ -1,7 +1,9 @@
import { dashboardCardKeys, type DashboardCardKey } from "@/Types/Dashboard";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "monitors"
| "team"
@@ -35,6 +37,7 @@ interface UIState {
language: string;
starPromptOpen: boolean;
chartType: ChartType;
dashboardVisibleCards: DashboardCardKey[];
}
const initialMode: ThemeMode = window?.matchMedia?.("(prefers-color-scheme: dark)")
@@ -74,6 +77,7 @@ const initialState: UIState = {
language: "en",
starPromptOpen: true,
chartType: "histogram",
dashboardVisibleCards: [...dashboardCardKeys],
};
const uiSlice = createSlice({
@@ -117,6 +121,9 @@ const uiSlice = createSlice({
setChartType: (state, action: PayloadAction<ChartType>) => {
state.chartType = action.payload;
},
setDashboardVisibleCards: (state, action: PayloadAction<DashboardCardKey[]>) => {
state.dashboardVisibleCards = action.payload;
},
},
});
@@ -133,4 +140,5 @@ export const {
setLanguage,
setStarPromptOpen,
setChartType,
setDashboardVisibleCards,
} = uiSlice.actions;
+7
View File
@@ -77,6 +77,13 @@ export const getMonitorDefaults = (
...base,
type: "docker",
url: data?.url || "",
secret: data?.secret || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
dockerTlsCa: data?.dockerTlsCa || "",
dockerTlsCert: data?.dockerTlsCert || "",
// The key is write-only; the API never returns it.
dockerTlsKey: "",
dockerTlsKeySet: data?.dockerTlsKeySet ?? false,
dockerLogsEnabled: data?.dockerLogsEnabled ?? false,
};
break;
+148 -15
View File
@@ -38,6 +38,7 @@ import {
PageSpeedStrategies,
DnsRecordTypes,
MonitorIntervalOptions,
MIN_MONITOR_INTERVAL_MS,
DefaultMonitorMatchMethod,
MonitorMatchMethods,
GeoCheckIntervalOptions,
@@ -50,6 +51,8 @@ import type { AppSettingsResponse } from "@/Types/Settings";
import {
stepFieldsFor,
monitorStepCount,
isCaptureDockerUrl,
isDockerTlsUrl,
type MonitorFormData,
} from "@/Validation/monitor";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
@@ -57,15 +60,62 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormAutocompleteField,
type AutocompleteOption,
} from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormSliderField } from "@/Components/inputs/forms/FormSliderField";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import type { ProxyResponse } from "@/Types/Proxy";
import { formatDuration } from "@/Utils/TimeUtils";
const httpMethodOptions = HttpMethods.map((method) => ({
value: method,
label: method,
}));
const PEM_FIELD_ROWS = 6;
// Hides the pasted key while the field is not focused. Ignored by browsers
// without -webkit-text-security, which then show the key as plain text.
const maskedInputSx = {
"&:not(.Mui-focused) textarea": { WebkitTextSecurity: "disc" },
};
interface IntervalOption extends AutocompleteOption {
id: number;
}
const INTERVAL_UNIT_MULTIPLIERS = [1000, 60000, 3600000, 86400000] as const;
const toIntervalOption = (
intervalMs: number,
locale: string | undefined
): IntervalOption => ({
id: intervalMs,
name: formatDuration(intervalMs, { long: true, locale }),
});
const filterIntervalOptions = (
options: IntervalOption[],
inputValue: string,
locale: string | undefined
): IntervalOption[] => {
const input = inputValue.trim();
if (input === "" || options.some((option) => option.name === input)) {
return options;
}
const amount = Number(input);
if (!Number.isFinite(amount) || amount <= 0) {
return [];
}
return INTERVAL_UNIT_MULTIPLIERS.map((multiplier) => amount * multiplier)
.filter(Number.isSafeInteger)
.map((ms) => toIntervalOption(ms, locale));
};
interface GeneralSettingsConfig {
urlLabel: string;
urlPlaceholder: string;
@@ -243,7 +293,7 @@ const getGeneralSettingsConfig = (
const CreateMonitorPage = () => {
const theme = useTheme();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { monitorId } = useParams();
const location = useLocation();
const navigate = useNavigate();
@@ -300,10 +350,13 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
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;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
const watchedInterval = watch("interval");
// Steps without an advanced section drop it, so the last step is the form's.
const totalSteps = monitorStepCount(watchedType);
@@ -329,11 +382,20 @@ 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]
);
const pemInputSx = { fontFamily: theme.typography.fontFamilyMonospace };
const { post, loading: isCreating } = usePost<MonitorFormData, Monitor>();
const { patch, loading: isUpdating } = usePatch<MonitorFormData, Monitor>();
const isSubmitting = isCreating || isUpdating;
@@ -459,16 +521,18 @@ const CreateMonitorPage = () => {
[t]
);
const intervalOptions = useMemo(
() =>
MonitorIntervalOptions.map((option) => ({
value: option.value,
label: t(
`pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}`
),
})),
[t]
);
const intervalOptions = useMemo(() => {
const options = MonitorIntervalOptions.map(({ value }) =>
toIntervalOption(value, i18n.resolvedLanguage)
);
if (
typeof watchedInterval === "number" &&
!options.some((option) => option.id === watchedInterval)
) {
options.push(toIntervalOption(watchedInterval, i18n.resolvedLanguage));
}
return options;
}, [watchedInterval, i18n.resolvedLanguage]);
const matchMethodOptions = useMemo(
() =>
@@ -654,8 +718,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")}
@@ -664,6 +728,12 @@ const CreateMonitorPage = () => {
)}
/>
)}
{isCaptureDocker && (
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.ignoreTls.option.tls.label")}
/>
)}
{generalSettingsConfig.showDnsServer && (
<FormTextField
name="dnsServer"
@@ -689,17 +759,80 @@ const CreateMonitorPage = () => {
/>
)}
{showStep(0) && watchedType === "docker" && isDockerTlsUrl(watchedUrl) && (
<ConfigBox
title={t("pages.createMonitor.form.dockerTls.title")}
subtitle={t("pages.createMonitor.form.dockerTls.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="dockerTlsCa"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.ca.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.ca.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsCert"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.cert.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.cert.placeholder"
)}
slotProps={{ input: { sx: pemInputSx } }}
/>
<FormTextField
name="dockerTlsKey"
multiline
rows={PEM_FIELD_ROWS}
fieldLabel={t("pages.createMonitor.form.dockerTls.option.key.label")}
placeholder={t(
"pages.createMonitor.form.dockerTls.option.key.placeholder"
)}
helperText={
existingMonitor?.dockerTlsKeySet
? t("pages.createMonitor.form.dockerTls.option.key.stored")
: undefined
}
autoComplete="off"
slotProps={{ input: { sx: { ...pemInputSx, ...maskedInputSx } } }}
/>
<FormSwitchField
name="ignoreTlsErrors"
label={t("pages.createMonitor.form.dockerTls.option.ignoreTls.label")}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t("pages.createMonitor.form.dockerTls.option.ignoreTls.description")}
</Typography>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.createMonitor.form.frequency.title")}
subtitle={t("pages.createMonitor.form.frequency.description")}
rightContent={
<FormSelectField
<FormAutocompleteField
name="interval"
fieldLabel={t(
"pages.createMonitor.form.frequency.option.frequency.label"
)}
options={intervalOptions}
filterOptions={(options, { inputValue }) =>
filterIntervalOptions(options, inputValue, i18n.resolvedLanguage)
}
getOptionDisabled={(option) => option.id < MIN_MONITOR_INTERVAL_MS}
disableClearable
/>
}
/>
@@ -828,7 +961,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")}
@@ -0,0 +1,54 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface DashboardCardProps {
title: string;
topRight?: React.ReactNode;
children: React.ReactNode;
}
export const DashboardCard = ({ title, topRight, children }: DashboardCardProps) => {
const theme = useTheme();
return (
<Box
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={`${theme.shape.borderRadius}px`}
padding={theme.spacing(LAYOUT.MD)}
display="flex"
flexDirection="column"
gap={theme.spacing(LAYOUT.SM)}
height="100%"
boxSizing="border-box"
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Typography
fontSize={typographyLevels.m}
fontWeight={500}
color={theme.palette.text.primary}
>
{title}
</Typography>
{topRight && (
<Typography
fontSize={typographyLevels.s}
color={theme.palette.text.secondary}
>
{topRight}
</Typography>
)}
</Stack>
{children}
</Box>
);
};
@@ -0,0 +1,69 @@
import { useState } from "react";
import Stack from "@mui/material/Stack";
import FormControlLabel from "@mui/material/FormControlLabel";
import { useTranslation } from "react-i18next";
import { Checkbox, Dialog } from "@/Components/inputs";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
interface EditCardsModalProps {
open: boolean;
visibleCards: Set<DashboardCardKey>;
onClose: () => void;
onSave: (visibleCards: Set<DashboardCardKey>) => void;
}
export const EditCardsModal = ({
open,
visibleCards,
onClose,
onSave,
}: EditCardsModalProps) => {
const { t } = useTranslation();
const [draft, setDraft] = useState<Set<DashboardCardKey>>(new Set(visibleCards));
const handleClose = () => {
setDraft(new Set(visibleCards));
onClose();
};
const handleToggle = (key: DashboardCardKey) => {
setDraft((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
return (
<Dialog
open={open}
title={t("pages.dashboard.editCardsModal.title")}
content={t("pages.dashboard.editCardsModal.description")}
onCancel={handleClose}
onConfirm={() => onSave(draft)}
confirmText={t("pages.dashboard.editCardsModal.done")}
maxWidth="xs"
fullWidth
>
<Stack>
{dashboardCardKeys.map((key: DashboardCardKey) => (
<FormControlLabel
key={key}
label={t(`pages.dashboard.cards.${key}`)}
control={
<Checkbox
checked={draft.has(key)}
disabled={draft.has(key) && draft.size === 1}
onChange={() => handleToggle(key)}
/>
}
/>
))}
</Stack>
</Dialog>
);
};
@@ -0,0 +1,28 @@
import { LAYOUT } from "@/Utils/Theme/constants";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
interface ProgressBarProps {
fillRatio: number;
}
export const ProgressBar = ({ fillRatio }: ProgressBarProps) => {
const theme = useTheme();
const clamped = Math.min(1, Math.max(0, fillRatio));
return (
<Box
height={theme.spacing(LAYOUT.XS)}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.disabledBackground}
overflow="hidden"
>
<Box
height="100%"
width={`${clamped * 100}%`}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.primary.main}
/>
</Box>
);
};
@@ -0,0 +1,42 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { SPACING } from "@/Utils/Theme/constants";
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
interface BarRowProps {
label: string;
value: string;
fillRatio: number;
valueColor?: string;
}
export const BarRow = ({ label, value, fillRatio, valueColor }: BarRowProps) => {
const theme = useTheme();
return (
<Stack gap={theme.spacing(SPACING.LG)}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.primary}
>
{label}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={valueColor ?? theme.palette.primary.main}
fontWeight={500}
>
{value}
</Typography>
</Stack>
<ProgressBar fillRatio={fillRatio} />
</Stack>
);
};
@@ -0,0 +1,58 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { ProgressBar } from "@/Pages/Dashboard/components/ProgressBar";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import type { MonitorsSummary } from "@/Types/Monitor";
interface MonitorStatusCardProps {
summary: MonitorsSummary | null;
}
export const MonitorStatusCard = ({ summary }: MonitorStatusCardProps) => {
const theme = useTheme();
const { t } = useTranslation();
const up = summary?.upMonitors ?? 0;
const total = summary?.totalMonitors ?? 0;
const ratio = total > 0 ? up / total : 0;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorStatus")}>
{total === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack
direction="row"
alignItems="baseline"
gap={theme.spacing(SPACING.XS)}
>
<Typography
fontSize={typographyLevels.xxl}
fontWeight={500}
color={theme.palette.text.primary}
>
{up}
</Typography>
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.secondary}
>
{t("pages.dashboard.monitorStatus.ofUp", { total })}
</Typography>
</Stack>
<ProgressBar fillRatio={ratio} />
</Stack>
)}
</DashboardCard>
);
};
@@ -0,0 +1,46 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { DashboardCard } from "@/Pages/Dashboard/components/DashboardCard";
import { BarRow } from "@/Pages/Dashboard/components/cards/BarRow";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import type { MonitorTypeCount } from "@/Types/Monitor";
import { getMonitorTypeLabel } from "@/Types/Monitor";
import { LAYOUT } from "@/Utils/Theme/constants";
import { typographyLevels } from "@/Utils/Theme/Palette";
interface MonitorsByTypeCardProps {
monitorsByType: MonitorTypeCount[];
}
export const MonitorsByTypeCard = ({ monitorsByType }: MonitorsByTypeCardProps) => {
const { t } = useTranslation();
const theme = useTheme();
const maxCount = monitorsByType[0]?.count ?? 1;
return (
<DashboardCard title={t("pages.dashboard.cards.monitorsByType")}>
{monitorsByType.length === 0 ? (
<Typography
fontSize={typographyLevels.m}
color={theme.palette.text.disabled}
>
{t("pages.dashboard.noData")}
</Typography>
) : (
<Stack gap={theme.spacing(LAYOUT.SM)}>
{monitorsByType.map(({ type, count }) => (
<BarRow
key={type}
label={getMonitorTypeLabel(type, t)}
value={String(count)}
fillRatio={count / maxCount}
valueColor={theme.palette.text.primary}
/>
))}
</Stack>
)}
</DashboardCard>
);
};
+96
View File
@@ -0,0 +1,96 @@
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 { MonitorsByTypeCard } from "@/Pages/Dashboard/components/cards/MonitorsByTypeCard";
import { EditCardsModal } from "@/Pages/Dashboard/components/EditCardsModal";
import { type DashboardCardKey, dashboardCardKeys } from "@/Types/Dashboard";
const Dashboard = () => {
const theme = useTheme();
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const [editOpen, setEditOpen] = useState(false);
const visibleCardsList = useSelector(
(state: RootState) => state.ui.dashboardVisibleCards
);
const visibleCards = useMemo(() => new Set(visibleCardsList), [visibleCardsList]);
const { monitors, summary } = useMonitorListController({
types: [...MonitorTypes],
checksLimit: 1,
refreshInterval: 30000,
rowsPerPageTable: "monitors",
rowsPerPageDefault: 100,
});
const monitorsByType = useMemo<MonitorTypeCount[]>(() => {
const countMap = new Map<MonitorTypeCount["type"], number>();
(monitors ?? []).forEach((m) =>
countMap.set(m.type, (countMap.get(m.type) ?? 0) + 1)
);
return [...countMap.entries()]
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
}, [monitors]);
const cardComponents: Record<DashboardCardKey, ReactNode> = {
monitorStatus: <MonitorStatusCard summary={summary} />,
monitorsByType: <MonitorsByTypeCard monitorsByType={monitorsByType} />,
};
return (
<BasePage headerKey="dashboard">
<Stack gap={theme.spacing(LAYOUT.SM)}>
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
variant="outlined"
size="small"
onClick={() => setEditOpen(true)}
>
{t("pages.dashboard.editCards")}
</Button>
</Stack>
<Box
display="grid"
gridTemplateColumns={{ xs: "1fr", md: "1fr 1fr" }}
gap={theme.spacing(LAYOUT.MD)}
>
{dashboardCardKeys
.filter((key) => visibleCards.has(key))
.map((key) => (
<Fragment key={key}>{cardComponents[key]}</Fragment>
))}
</Box>
</Stack>
<EditCardsModal
open={editOpen}
visibleCards={visibleCards}
onClose={() => setEditOpen(false)}
onSave={(next) => {
dispatch(setDashboardVisibleCards([...next]));
setEditOpen(false);
}}
/>
</BasePage>
);
};
export default Dashboard;
Whitespace-only changes.
@@ -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,10 +13,11 @@ 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";
import type { DateRange } from "@/Types/Query";
const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [
{
@@ -24,20 +25,29 @@ const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats
key: "avgCpuPct",
color: theme.palette.success.main,
rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0),
formatter: formatPercentage,
labelKey: "common.charts.histogram.avgCpuUsage",
},
{
title: t("common.charts.labels.memoryUsage"),
key: "avgMemoryUsedBytes",
color: theme.palette.primary.main,
rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0),
formatter: prettyBytes,
labelKey: "common.charts.histogram.avgMemoryUsage",
},
];
export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
export const TabOverview = ({
stats,
range,
}: {
stats: DockerContainerStats;
range: DateRange;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { ports, mounts } = stats?.latest?.container ?? {};
const dedupedPorts = dedupeDockerPorts(ports ?? []);
return (
@@ -58,6 +68,9 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
dataKey={config.key}
strokeColor={config.color}
gradientStartColor={config.color}
range={range}
formatter={config.formatter}
labelKey={config.labelKey}
/>
</Grid>
);
@@ -75,11 +88,17 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
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>
@@ -98,6 +117,7 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => {
Volumes
</Typography>
<Stack>
{!mounts?.length && <Typography>{PLACEHOLDER}</Typography>}
{mounts?.map((mount) => {
return (
<Typography key={`${mount.source}/${mount.destination}`}>
@@ -21,7 +21,7 @@ const TAB_MAP = {
logs: 1,
} as const;
const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => {
const [activeTab, setActiveTab] = useState<number>(TAB_MAP[open]);
const [dateRange, setDateRange] = useState<DateRange>("recent");
const { monitorId, containerName } = useParams<{
@@ -69,7 +69,12 @@ const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => {
<Tab label={t("pages.docker.container.tabs.overview")} />
<Tab label={t("pages.docker.container.tabs.logs")} />
</Tabs>
{activeTab === 0 && <TabOverview stats={stats} />}
{activeTab === 0 && (
<TabOverview
stats={stats}
range={dateRange}
/>
)}
{activeTab === 1 && (
<TabLogs
monitorId={monitorId}
@@ -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>;
},
},
];
@@ -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();
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(1);
const [activeTab, setActiveTab] = useState<number>(0);
return (
<BasePage headerKey="logs">
<Tabs
+6 -5
View File
@@ -1,4 +1,4 @@
import NotFoundSvg from "@/assets/Images/sushi_404.svg";
import NotFoundSvg from "@/assets/Images/sushi_404.svg?react";
import { Button } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -36,10 +36,11 @@ const NotFoundPage = ({ title, desc }: NotFoundProps) => {
alignItems="center"
>
<Box
component="img"
src={NotFoundSvg}
alt="404"
maxHeight={"25rem"}
component={NotFoundSvg}
role="img"
aria-label="404"
width="100%"
maxHeight="25rem"
/>
<Typography
component="h1"
@@ -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>;
};
+7
View File
@@ -8,6 +8,9 @@ import AuthRegister from "@/Pages/Auth/Register";
import AuthForgotPassword from "@/Pages/Auth/Recovery";
import AuthSetNewPassword from "@/Pages/Auth/SetNewPassword";
// Dashboard
import Dashboard from "@/Pages/Dashboard";
// Uptime
import Uptime from "@/Pages/Uptime/Monitors";
import UptimeDetails from "@/Pages/Uptime/Details";
@@ -92,6 +95,10 @@ const Routes = () => {
path="/"
element={<Navigate to="/uptime" />}
/>
<Route
path="dashboard"
element={<Dashboard />}
/>
<Route
path="/uptime"
element={<Uptime />}
+2
View File
@@ -0,0 +1,2 @@
export const dashboardCardKeys = ["monitorStatus", "monitorsByType"] as const;
export type DashboardCardKey = (typeof dashboardCardKeys)[number];
+17
View File
@@ -60,6 +60,15 @@ export const monitorTypeLabelKey: Record<SelectableMonitorType, string> &
pagespeed: "optionPagespeed",
};
export const getMonitorTypeLabel = (
type: MonitorType,
t: (key: string) => string
): string => {
const monitorLabel = monitorTypeLabelKey[type];
return monitorLabel ? t(`pages.common.monitors.monitorTypes.${monitorLabel}`) : type;
};
export const MIN_MONITOR_INTERVAL_MS = 10000;
export const MonitorIntervalOptions = [
{ value: 15000, labelKey: "fifteenSeconds" },
{ value: 30000, labelKey: "thirtySeconds" },
@@ -160,6 +169,9 @@ export interface Monitor {
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dockerLogsEnabled?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKeySet?: boolean;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
@@ -180,6 +192,11 @@ export interface MonitorsSummary {
breachedMonitors: number;
}
export interface MonitorTypeCount {
type: MonitorType;
count: number;
}
export interface MonitorsWithChecksResponse {
count: number;
monitors: MonitorWithChecks[];
+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 = "—";
+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
+46 -3
View File
@@ -104,9 +104,52 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => {
return formatted;
};
export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => {
if (verbose) {
return prettyMilliseconds(ms, { verbose: true });
type DurationUnit = "day" | "hour" | "minute" | "second";
interface FormatDurationOptions {
long?: boolean;
relative?: boolean;
hasSpace?: boolean;
locale?: string;
}
const DURATION_UNITS: Array<{ unit: DurationUnit; milliseconds: number }> = [
{ unit: "day", milliseconds: MS_PER_DAY },
{ unit: "hour", milliseconds: MS_PER_HOUR },
{ unit: "minute", milliseconds: MS_PER_MINUTE },
{ unit: "second", milliseconds: MS_PER_SECOND },
];
const FALLBACK_DURATION_UNIT = DURATION_UNITS[DURATION_UNITS.length - 1];
const getDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => Math.abs(ms) >= milliseconds) ??
FALLBACK_DURATION_UNIT;
const getExactDurationUnit = (ms: number) =>
DURATION_UNITS.find(({ milliseconds }) => ms !== 0 && ms % milliseconds === 0) ??
FALLBACK_DURATION_UNIT;
export const formatDuration = (
ms: number,
{ long = false, relative = false, hasSpace = true, locale }: FormatDurationOptions = {}
): string => {
if (relative) {
const { unit, milliseconds } = getDurationUnit(ms);
const value = Math.sign(ms) * Math.floor(Math.abs(ms) / milliseconds);
return new Intl.RelativeTimeFormat(locale, {
numeric: "always",
style: long ? "long" : "narrow",
}).format(-value, unit);
}
if (long) {
const { unit, milliseconds } = getExactDurationUnit(ms);
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
}).format(ms / milliseconds);
}
const formatted = prettyMilliseconds(ms, {
+76 -4
View File
@@ -6,6 +6,7 @@ import {
HttpMethods,
PageSpeedStrategies,
ProxyModes,
MIN_MONITOR_INTERVAL_MS,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -27,7 +28,8 @@ const baseSchema = z.object({
description: z.string().optional(),
interval: z
.number()
.min(15000, "Interval must be at least 15 seconds")
.int("Interval must be a whole number of milliseconds")
.min(MIN_MONITOR_INTERVAL_MS, "Interval must be at least 10 seconds")
.register(monitorStepRegistry, { step: 1 }),
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
@@ -108,10 +110,47 @@ const portSchema = baseSchema.extend({
.max(65535, "Port must be at most 65535"),
});
// Docker monitor schema
// 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"),
url: z
.string()
.min(1, "Docker host URL is required")
.refine(
(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(),
// blank on edit keeps the stored key.
dockerTlsKey: z.string(),
// Read only mirror of the server flag, stripped on submit
dockerTlsKeySet: z.boolean(),
dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }),
});
@@ -217,7 +256,8 @@ const monitorSchemaUnion = z.discriminatedUnion("type", [
dnsSchema,
]);
// Mirrors the server's refineProxySelection (monitorValidation.ts)
// Mirrors the server's refineProxySelection and refineDockerTls
// (monitorValidation.ts). PEM contents are only parsed on the server.
export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
ctx.addIssue({
@@ -226,6 +266,38 @@ export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
message: "A proxy must be selected when proxy mode is custom",
});
}
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({
code: "custom",
path: ["dockerTlsCert"],
message: "Client certificate is required for a TLS Docker host",
});
}
if (!data.dockerTlsKeySet && !data.dockerTlsKey.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsKey"],
message: "Client key is required for a TLS Docker host",
});
}
if (!data.ignoreTlsErrors && !data.dockerTlsCa.trim()) {
ctx.addIssue({
code: "custom",
path: ["dockerTlsCa"],
message: "CA certificate is required unless TLS errors are ignored",
});
}
}
});
export type MonitorFormData = z.infer<typeof monitorSchema>;
File diff suppressed because it is too large. Load diff

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 286 KiB

+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": {
+4
View File
@@ -448,6 +448,7 @@
},
"statBoxes": {
"activeFor": "Aktiv seit",
"checkingEvery": "Check alle {{interval}}",
"certificateExpiry": "Zertifikatablauf",
"lastCheck": "Letzter Check",
"lastResponseTime": "Letzte Antwortzeit",
@@ -1377,6 +1378,9 @@
"maintenance": "Wartung",
"paused": "Pausiert",
"up": "Betriebsbereit"
},
"incidents": {
"errorLoading": "Vorfälle konnten nicht geladen werden."
}
},
"statusBar": {
+57 -3
View File
@@ -80,7 +80,9 @@
"avg": "Avg: {{value}}",
"max": "Max: {{value}}",
"responseTime": "Response time: {{value}}",
"total": "Total: {{value}}"
"total": "Total: {{value}}",
"avgCpuUsage": "Average CPU usage: {{value}}",
"avgMemoryUsage": "Average memory usage: {{value}}"
},
"phases": {
"dns": "DNS",
@@ -224,7 +226,8 @@
"alerting": "Alerting",
"reporting": "Reporting",
"configuration": "Configuration"
}
},
"dashboard": "Dashboard"
},
"bottomMenu": {
"support": "Support",
@@ -545,6 +548,7 @@
},
"statBoxes": {
"activeFor": "Active for",
"checkingEvery": "Checking every {{interval}}",
"certificateExpiry": "Certificate expiry",
"domainExpiry": "Domain expiry",
"lastCheck": "Last check",
@@ -716,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. 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.",
@@ -832,6 +836,29 @@
"description": "This will permanently delete all monitoring history, statistics, and check data for your team. This action cannot be undone.",
"confirm": "Yes, clear all stats"
}
},
"dockerTls": {
"title": "TLS credentials",
"description": "Connect to a remote Docker daemon over mutual TLS. Paste the PEM files you would pass to docker --tlsverify. The client key is encrypted before it is stored and is never shown again.",
"option": {
"ca": {
"label": "CA certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"cert": {
"label": "Client certificate",
"placeholder": "-----BEGIN CERTIFICATE-----"
},
"key": {
"label": "Client key",
"placeholder": "-----BEGIN PRIVATE KEY-----",
"stored": "A key is stored. Leave blank to keep it."
},
"ignoreTls": {
"label": "Ignore TLS/SSL errors",
"description": "Skips verifying the daemon's certificate, so the CA certificate becomes optional. The client certificate and key are still required because the daemon verifies them."
}
}
}
}
},
@@ -1685,6 +1712,13 @@
"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": {
@@ -1882,6 +1916,26 @@
"title": "Uptime monitors",
"description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down."
}
},
"dashboard": {
"noData": "No data available at this moment.",
"monitorStatus": {
"ofUp": "of {{total}} up"
},
"cards": {
"monitorStatus": "Monitor status",
"monitorsByType": "Monitors by type"
},
"header": {
"title": "Dashboard",
"description": "See every monitor type in one place and spot what needs attention."
},
"editCards": "Edit cards",
"editCardsModal": {
"title": "Edit cards",
"description": "Select the cards to display on your dashboard.",
"done": "Done"
}
}
}
}
+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": {
+1
View File
@@ -9,6 +9,7 @@ services:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db
- CLIENT_HOST=${CLIENT_HOST:-http://localhost:52345}
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
stop_grace_period: 60s
healthcheck:
test:
+11
View File
@@ -27,6 +27,17 @@ CLIENT_HOST=http://localhost:5173
# ----------------------
ORIGIN=localhost
# Encryption
# ------------------
# Encrypts stored Docker TLS client keys. Not needed for socket-only Docker monitors.
# Generate a key: openssl rand -base64 32
# Key list: comma-separated; the first key encrypts, every key decrypts.
# Split deployments: set the same value on the API and every worker.
# Rotate a key: 1. deploy OLD_KEY,NEW_KEY everywhere so every process can read both
# 2. deploy NEW_KEY,OLD_KEY everywhere so new writes use the new key
# 3. wait for the worker to re-encrypt every row, then drop OLD_KEY
# ENCRYPTION_KEY=<output of openssl rand -base64 32>
# Feature Flags
# -------------
# Set to "false" to disable the selectable theme feature on status pages.
+404 -21
View File
@@ -422,9 +422,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -466,6 +463,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -513,6 +522,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -640,9 +650,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -684,6 +691,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -733,6 +752,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -929,6 +949,9 @@
{
"$ref": "#/components/schemas/PushoverNotification"
},
{
"$ref": "#/components/schemas/SignalgridNotification"
},
{
"$ref": "#/components/schemas/TwilioNotification"
},
@@ -949,6 +972,7 @@
"teams": "#/components/schemas/TeamsNotification",
"telegram": "#/components/schemas/TelegramNotification",
"pushover": "#/components/schemas/PushoverNotification",
"signalgrid": "#/components/schemas/SignalgridNotification",
"twilio": "#/components/schemas/TwilioNotification",
"ntfy": "#/components/schemas/NtfyNotification"
}
@@ -1380,6 +1404,34 @@
"accessToken": "a1234567890abcdef"
}
},
"SignalgridNotification": {
"type": "object",
"properties": {
"notificationName": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": ["signalgrid"]
},
"address": {
"type": "string",
"minLength": 1
},
"accessToken": {
"type": "string",
"minLength": 1
}
},
"required": ["notificationName", "type", "address", "accessToken"],
"example": {
"notificationName": "Signalgrid",
"type": "signalgrid",
"address": "your-channel",
"accessToken": "your-client-key"
}
},
"TwilioNotification": {
"type": "object",
"properties": {
@@ -7090,9 +7142,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -7134,6 +7183,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -7183,6 +7244,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -7655,9 +7717,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number"
},
@@ -7699,6 +7758,18 @@
"geoCheckInterval": {
"type": "number"
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"type": "string"
},
"dockerTlsCert": {
"type": "string"
},
"dockerTlsKeySet": {
"type": "boolean"
},
"dnsServer": {
"type": "string"
},
@@ -7748,6 +7819,7 @@
"geoCheckEnabled",
"geoCheckLocations",
"geoCheckInterval",
"dockerLogsEnabled",
"teamId",
"userId",
"createdAt",
@@ -7984,6 +8056,231 @@
}
}
},
"/monitors/docker/details/{monitorId}/containers/{containerName}/logs": {
"get": {
"tags": ["monitors"],
"summary": "Get stored log lines for one container on a Docker host monitor, newest first",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "monitorId",
"in": "path"
},
{
"schema": {
"type": "string",
"minLength": 1
},
"required": true,
"name": "containerName",
"in": "path"
},
{
"schema": {
"type": "string",
"format": "date-time"
},
"required": false,
"name": "before",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date-time"
},
"required": false,
"name": "after",
"in": "query"
},
{
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 20
},
"required": false,
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [true]
},
"msg": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"logs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"metadata": {
"type": "object",
"properties": {
"monitorId": {
"type": "string"
},
"teamId": {
"type": "string"
},
"containerId": {
"type": "string"
},
"containerName": {
"type": "string"
}
},
"required": ["monitorId", "teamId", "containerId", "containerName"]
},
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ts": {
"type": "string"
},
"stream": {
"type": "string",
"enum": ["stdout", "stderr"]
},
"text": {
"type": "string"
}
},
"required": ["ts", "stream", "text"]
}
},
"gap": {
"type": "boolean"
},
"checkedAt": {
"type": "string"
},
"expiry": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": ["id", "metadata", "lines", "gap", "checkedAt", "expiry", "createdAt", "updatedAt"]
}
},
"nextCursor": {
"type": "string",
"nullable": true
}
},
"required": ["logs", "nextCursor"]
}
},
"required": ["success", "msg", "data"]
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"enum": [false]
},
"msg": {
"type": "string"
},
"data": {
"nullable": true
}
},
"required": ["success", "msg"]
}
}
}
}
}
}
},
"/monitors/pagespeed/details/{monitorId}": {
"get": {
"tags": ["monitors"],
@@ -8908,9 +9205,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"jsonPath": {
"anyOf": [
{
@@ -9011,6 +9305,42 @@
"type": "number",
"minimum": 300000
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsCert": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsKey": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -9604,9 +9934,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"cpuAlertThreshold": {
"type": "number",
"default": 100
@@ -9702,6 +10029,10 @@
"minimum": 300000,
"default": 300000
},
"dockerLogsEnabled": {
"type": "boolean",
"default": false
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -10137,9 +10468,6 @@
"secret": {
"type": "string"
},
"sshPrivateKey": {
"type": "string"
},
"ignoreTlsErrors": {
"type": "boolean"
},
@@ -10269,6 +10597,42 @@
"type": "number",
"minimum": 300000
},
"dockerLogsEnabled": {
"type": "boolean"
},
"dockerTlsCa": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsCert": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dockerTlsKey": {
"anyOf": [
{
"type": "string"
},
{
"type": "string",
"enum": [""]
}
]
},
"dnsServer": {
"type": "string",
"minLength": 1
@@ -12930,6 +13294,25 @@
"globalProxyId": {
"type": "string",
"nullable": true
},
"egressCheckEnabled": {
"type": "boolean"
},
"egressCheckTargets": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 253
},
"maxItems": 10
},
"egressNotifications": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-fA-F]{24}$"
}
}
}
}
+7
View File
@@ -18,6 +18,7 @@
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.23",
"dockerode": "5.0.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
@@ -5803,6 +5804,12 @@
"node": ">=18"
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+2 -1
View File
@@ -15,7 +15,7 @@
"worker": "QUEUE_MODE=worker node ./dist/index.js",
"worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js",
"clean": "rm -rf dist",
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates && mkdir -p dist/service && cp -r src/service/protos dist/service/",
"typeCheck": "tsc --noEmit",
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"lint": "eslint .",
@@ -42,6 +42,7 @@
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dayjs": "^1.11.23",
"dockerode": "5.0.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+2 -1
View File
@@ -5,6 +5,7 @@ import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from
import {
registrationBodyValidation,
registerInviteTokenValidation,
loginValidation,
recoveryValidation,
recoveryTokenBodyValidation,
@@ -48,7 +49,7 @@ class AuthController implements IAuthController {
registerUser = catchAsync(async (req: Request, res: Response) => {
const newUser = req.body.user;
const newUserToken = req.body.token;
const newUserToken = registerInviteTokenValidation.parse(req.body.token);
if (newUser?.email) {
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
@@ -10,7 +10,10 @@ type WhoisModule = typeof whoiser;
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
const cert = await checker(hostname);
// An explicit port in the URL is the most visible to the user, so it takes precedence over monitor.port.
const urlPort = monitorUrl.port ? Number(monitorUrl.port) : undefined;
const port = urlPort ?? monitor.port;
const cert = await checker(hostname, port !== undefined ? { port } : undefined);
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
@@ -6,6 +6,7 @@ import { AppError } from "@/utils/AppError.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
import { IEmailService } from "@/service/emailService.js";
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { IEgressStateService } from "@/domain/egress/egress-state.service.js";
import { Settings } from "@/domain/app-settings/app-settings.type.js";
export interface ISettingsController {
@@ -18,10 +19,17 @@ class SettingsController implements ISettingsController {
private settingsService: ISettingsService;
private emailService: IEmailService;
private proxiesService: IProxiesService;
constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) {
private egressStateService: IEgressStateService;
constructor(
settingsService: ISettingsService,
emailService: IEmailService,
proxiesService: IProxiesService,
egressStateService: IEgressStateService
) {
this.settingsService = settingsService;
this.emailService = emailService;
this.proxiesService = proxiesService;
this.egressStateService = egressStateService;
}
buildAppSettings = async (dbSettings: Settings) => {
@@ -70,7 +78,14 @@ class SettingsController implements ISettingsController {
}
}
const previousSettings = await this.settingsService.getDBSettings();
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
// Switching the egress check on or off starts from a clean state: no degraded episode, no pending recovery job
if (validatedBody.egressCheckEnabled !== undefined && validatedBody.egressCheckEnabled !== previousSettings.egressCheckEnabled) {
await this.egressStateService.reset();
}
const returnSettings = await this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
@@ -7,6 +7,8 @@ import {
getStatusPageQueryValidation,
imageValidation,
resolveStatusPageQueryValidation,
getPublicMonitorIncidentsParamValidation,
getPublicMonitorIncidentsQueryValidation,
} from "@/api/validation/statusPageValidation.js";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
@@ -17,6 +19,7 @@ export interface IStatusPageController {
createStatusPage: RequestHandler;
updateStatusPage: RequestHandler;
getStatusPageByUrl: RequestHandler;
getPublicMonitorIncidents: RequestHandler;
resolveStatusPageByDomain: RequestHandler;
getStatusPagesByTeamId: RequestHandler;
deleteStatusPage: RequestHandler;
@@ -85,6 +88,19 @@ class StatusPageController implements IStatusPageController {
});
});
getPublicMonitorIncidents = catchAsync(async (req: Request, res: Response) => {
const { url, monitorId } = getPublicMonitorIncidentsParamValidation.parse(req.params);
const { date } = getPublicMonitorIncidentsQueryValidation.parse(req.query);
const incidents = await this.statusPageService.getPublicMonitorIncidents(url, monitorId, date, req.user?.teamId);
return res.status(200).json({
success: true,
msg: "Incidents retrieved successfully",
data: { incidents },
});
});
resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => {
const { range } = resolveStatusPageQueryValidation.parse(req.query);
const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined);
@@ -16,7 +16,15 @@ export const isPublicStatusPageApiPath = (method: string, path: string): boolean
return true;
}
if (!slug || slug === "team" || slug.includes("/")) {
if (!slug || slug === "team") {
return false;
}
if (slug.includes("/")) {
const parts = slug.split("/");
if (parts.length === 3 && parts[1] === "incidents") {
return true;
}
return false;
}
@@ -14,6 +14,7 @@ export const createStatusPageRoutes = (
router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.updateStatusPage);
router.get("/resolve", statusPageController.resolveStatusPageByDomain);
router.get("/:url", verifyStatusPageAccess, statusPageController.getStatusPageByUrl);
router.get("/:url/incidents/:monitorId", verifyStatusPageAccess, statusPageController.getPublicMonitorIncidents);
router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.deleteStatusPage);
return router;
};
@@ -23,6 +23,8 @@ export const registrationBodyValidation = z.object({
inviteToken: z.string().optional().default(""),
});
export const registerInviteTokenValidation = z.string().optional().default("");
export const recoveryValidation = z.object({
email: z.email("Must be a valid email address"),
});
+70 -8
View File
@@ -15,6 +15,9 @@ 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 { isCaptureDockerUrl, isDockerSocketUrl, isDockerTlsUrl } from "@/utils/dockerHost.js";
import { X509Certificate } from "node:crypto";
import { keyMatchesCertificate, parseCertificates, parsePrivateKey } from "@/utils/pem.js";
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
@@ -120,19 +123,66 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
}
};
const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || data.url === undefined) return;
if (!dockerUrlRegex.test(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 or an absolute socket path",
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;
ignoreTlsErrors?: boolean;
dockerTlsCa?: string;
dockerTlsCert?: string;
dockerTlsKey?: string;
};
const refineDockerTls = (mode: "create" | "edit") => (data: DockerTlsFields, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || !isDockerTlsUrl(data.url)) return;
const issue = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
if (!data.dockerTlsCert) issue("dockerTlsCert", "TLS certificate is required for a TLS Docker host");
if (mode === "create" && !data.dockerTlsKey) issue("dockerTlsKey", "TLS key is required for a TLS Docker host");
if (!data.ignoreTlsErrors && !data.dockerTlsCa) issue("dockerTlsCa", "CA certificate is required unless TLS errors are ignored");
let certificate: X509Certificate | undefined;
try {
if (data.dockerTlsCa) parseCertificates(data.dockerTlsCa);
} catch (error: unknown) {
issue("dockerTlsCa", error instanceof Error ? error.message : "Docker TLS CA error");
}
try {
if (data.dockerTlsCert) [certificate] = parseCertificates(data.dockerTlsCert);
} catch (error) {
issue("dockerTlsCert", error instanceof Error ? error.message : "Docker TLS cert error");
}
try {
if (data.dockerTlsKey) {
const key = parsePrivateKey(data.dockerTlsKey);
if (certificate && !keyMatchesCertificate(key, certificate)) issue("dockerTlsKey", "Key does not match certificate");
}
} catch (error: unknown) {
issue("dockerTlsKey", error instanceof Error ? error.message : "Docker TLS key error");
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -170,6 +220,9 @@ export const createMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -178,7 +231,9 @@ export const createMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineCaptureSecret)
.superRefine(refineDockerTls("create"));
export const editMonitorBodyValidation = z
.object({
@@ -215,6 +270,9 @@ export const editMonitorBodyValidation = z
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z.number().min(300000).optional(),
dockerLogsEnabled: z.boolean().optional(),
dockerTlsCa: z.union([z.string(), z.literal("")]).optional(),
dockerTlsCert: z.union([z.string(), z.literal("")]).optional(),
dockerTlsKey: z.union([z.string(), z.literal("")]).optional(),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
})
@@ -223,7 +281,8 @@ export const editMonitorBodyValidation = z
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection)
.superRefine(refineDockerUrl);
.superRefine(refineDockerUrl)
.superRefine(refineDockerTls("edit"));
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -291,7 +350,6 @@ const importedMonitorSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)).default([]),
geoCheckInterval: z.number().min(300000).default(300000),
dockerLogsEnabled: z.boolean().default(false),
dnsServer: dnsServerValidation.optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
createdAt: z.string().optional(),
@@ -302,7 +360,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"),
@@ -381,6 +440,9 @@ export const monitorResponseSchema = z
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
dockerLogsEnabled: z.boolean(),
dockerTlsCa: z.string().optional(),
dockerTlsCert: z.string().optional(),
dockerTlsKeySet: z.boolean().optional(),
dnsServer: z.string().optional(),
dnsRecordType: z.enum(DnsRecordTypes).optional(),
teamId: z.string(),
@@ -1,6 +1,17 @@
import { CHECK_TTL_SENTINEL } from "@/domain/checks/check.type.js";
import { MAX_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
import { parseEgressTarget } from "@/utils/egressTarget.js";
import { z } from "zod";
// A reliability target is a bare host/IP (ICMP ping), host:port (TCP connect) or an http(s) URL.
// Parsed with the same function the probe uses, so nothing the validator accepts can fail to probe.
const egressTargetValidation = z
.string()
.trim()
.min(1)
.max(253)
.refine((target) => parseEgressTarget(target) !== null, "Enter a hostname or IP address, host:port, or http(s) URL");
//****************************************
// Settings Validations
//****************************************
@@ -42,6 +53,10 @@ export const updateAppSettingsBodyValidation = z
.optional(),
globalProxyEnabled: z.boolean().optional(),
globalProxyId: z.string().nullable().optional(),
egressCheckEnabled: z.boolean().optional(),
egressCheckTargets: z.array(egressTargetValidation).max(MAX_EGRESS_TARGETS).optional(), // empty list falls back to DEFAULT_EGRESS_TARGETS
egressNotifications: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid notification id")).optional(),
})
.strip()
.superRefine((body, ctx) => {
@@ -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"),
+6 -1
View File
@@ -36,7 +36,12 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
return {
authController: new AuthController(apiServices.userService),
monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService),
settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService),
settingsController: new SettingsController(
apiServices.settingsService,
apiServices.emailService,
apiServices.proxiesService,
apiServices.egressStateService
),
checkController: new CheckController(apiServices.checkService),
geoCheckController: new GeoCheckController(apiServices.geoChecksService),
inviteController: new InviteController(apiServices.inviteService),
+36
View File
@@ -3,6 +3,39 @@ import { DbTypes, LogLevels, QueueModes } from "@/domain/app-settings/app-settin
import { booleanCoercion } from "@/api/validation/shared.js";
import { ILogger } from "@/utils/logger.js";
// Padded standard base64 of exactly 32 bytes, as produced by `openssl rand -base64 32`
const ENCRYPTION_KEY_ENTRY = /^[A-Za-z0-9+/]{43}=$/;
export const encryptionKeyList = z
.string()
.optional()
.transform((raw, ctx) => {
if (raw === undefined) return [];
const entries = raw
.split(",")
.map((entry, index) => ({ entry: entry.trim(), slot: index + 1 }))
.filter(({ entry }) => entry.length > 0);
const seen = new Set<string>();
const keys: Buffer[] = [];
entries.forEach(({ entry, slot }) => {
if (!ENCRYPTION_KEY_ENTRY.test(entry)) {
ctx.addIssue({
code: "custom",
message: `ENCRYPTION_KEY entry ${slot} must be 32 bytes as padded standard base64 (openssl rand -base64 32)`,
});
return;
}
const decoded = Buffer.from(entry, "base64");
const fingerprint = decoded.toString("hex");
if (seen.has(fingerprint)) {
ctx.addIssue({ code: "custom", message: `ENCRYPTION_KEY entry ${slot} duplicates an earlier entry` });
}
seen.add(fingerprint);
keys.push(decoded);
});
return keys;
});
const envSchema = z.object({
// Server Configuration
PORT: z.string().default("52345"),
@@ -36,6 +69,9 @@ const envSchema = z.object({
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
// Encryption
ENCRYPTION_KEY: encryptionKeyList,
});
export type ValidatedEnv = z.infer<typeof envSchema>;
+7 -1
View File
@@ -7,6 +7,7 @@ import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
import { EgressStateService, IEgressStateService } from "@/domain/egress/egress-state.service.js";
import { SharedServices } from "@/config/services.shared.js";
// Third-party
@@ -24,6 +25,7 @@ export interface ApiServices extends SharedServices {
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
proxiesService: IProxiesService;
egressStateService: IEgressStateService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
@@ -48,6 +50,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
maintenanceWindowsRepository,
jobsRepository,
proxiesRepository,
egressStateRepository,
} = shared;
const userService = new UserService({
@@ -80,6 +83,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
encryptionService: shared.encryptionService,
});
const maintenanceWindowService = new MaintenanceWindowService({
@@ -95,10 +99,11 @@ 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);
const egressStateService = new EgressStateService(egressStateRepository, jobsRepository);
return {
...shared,
worker: jobScheduler,
@@ -110,5 +115,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
tagsService,
diagnosticService,
proxiesService,
egressStateService,
};
};
+15 -2
View File
@@ -1,6 +1,6 @@
import { Mongoose } from "mongoose";
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import * as nodeCrypto from "node:crypto";
import fs from "fs";
import path from "path";
import nodemailer from "nodemailer";
@@ -20,6 +20,7 @@ import { INotificationsService, NotificationsService } from "@/domain/notificati
import { IEmailService, EmailService } from "@/service/emailService.js";
import { GlobalPingService } from "@/service/globalPingService.js";
import { ILogger } from "@/utils/logger.js";
import { IEncryptionService, EncryptionService } from "@/service/encryption/encryptionService.js";
// Notification providers
import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js";
@@ -55,6 +56,7 @@ import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
// Mongo repository implementations
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
@@ -75,6 +77,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
import MongoEgressStateRepository from "@/domain/egress/egress-state.repository.mongo.js";
import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js";
// Shared infrastructure + business services that both the API and the worker process construct.
@@ -83,6 +86,7 @@ export interface SharedServices {
db: IDb<Mongoose>;
settingsService: ISettingsService;
emailService: IEmailService;
encryptionService: IEncryptionService;
notificationMessageBuilder: INotificationMessageBuilder;
incidentService: IIncidentService;
checkService: ICheckService;
@@ -112,19 +116,25 @@ export interface SharedServices {
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
proxiesRepository: IProxiesRepository;
egressStateRepository: IEgressStateRepository;
}
export const buildShared = async ({
logger,
envSettings,
settingsService,
encryptionKeys,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
encryptionKeys: Buffer[];
}): Promise<SharedServices> => {
// Shared worker ID
const workerId = `${hostname()}:${process.pid}:${randomUUID()}`;
const workerId = `${hostname()}:${process.pid}:${nodeCrypto.randomUUID()}`;
// Create the encryption service
const encryptionService = new EncryptionService(encryptionKeys, logger, nodeCrypto);
// Create DB
let db: IDb<Mongoose> | null = null;
@@ -150,6 +160,7 @@ export const buildShared = async ({
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
const proxiesRepository = new MongoProxiesRepository();
const egressStateRepository = new MongoEgressStateRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
@@ -213,6 +224,7 @@ export const buildShared = async ({
db,
settingsService,
emailService,
encryptionService,
notificationMessageBuilder,
incidentService,
checkService,
@@ -238,6 +250,7 @@ export const buildShared = async ({
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
egressStateRepository,
};
return sharedServices;
};
+56 -32
View File
@@ -16,17 +16,16 @@ import { SharedServices } from "@/config/services.shared.js";
import { INetworkService, NetworkService } from "@/service/networkService.js";
import { IBufferService, BufferService } from "@/service/bufferService.js";
import { IStatusService, StatusService } from "@/service/statusService.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
import { MonitorStatusPolicy } from "@/worker/worker.monitor-status-policy.js";
import { SecretsRotationService } from "@/service/encryption/secretsRotationService.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 { IEgressService, EgressService } from "@/domain/egress/egress.service.js";
// Network providers
import { PingProvider } from "@/service/network/PingProvider.js";
import { HttpProvider } from "@/service/network/HttpProvider.js";
@@ -39,11 +38,14 @@ import { GameProvider } from "@/service/network/GameProvider.js";
import { GrpcProvider } from "@/service/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/network/DNSProvider.js";
import { AppError } from "@/utils/AppError.js";
import { WorkerPipeline } from "@/worker/worker.pipeline.js";
export interface WorkerServices {
worker: IQueueWorker;
networkService: INetworkService;
bufferService: IBufferService;
statusService: IStatusService;
egressService: IEgressService;
}
export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig): Promise<WorkerServices> => {
@@ -59,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
jobsRepository,
queueWorkersRepository,
monitorsRepository,
encryptionService,
checksRepository,
geoChecksRepository,
dockerLogsRepository,
@@ -67,16 +70,17 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
egressStateRepository,
} = shared;
// ***********************
// Network providers
// ***********************
const pingProvider = new PingProvider(ping);
const pingProvider = new PingProvider(ping, net);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
const dockerProvider = new DockerProvider(logger, Docker, encryptionService, httpProvider);
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
@@ -97,35 +101,33 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
]);
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository);
const bufferService = new BufferService(logger, geoChecksService, dockerLogsService, settingsService, (checks): Promise<void> =>
pipeline.ingestChecks(checks)
);
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
const monitorStatusPolicy = new MonitorStatusPolicy();
// ***********************
// Reactors and dispatcher
// Handles notifications and incidents
// ***********************
const egressService = new EgressService(settingsService, egressStateRepository, jobsRepository, networkService, 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,
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
@@ -142,27 +144,49 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
checksRepository,
incidentsRepository,
geoChecksRepository,
dockerLogsRepository
dockerLogsRepository,
egressService
);
const handlers: JobHandlers = {
check: pipeline.handleCheck,
evaluate: pipeline.handleEvaluate,
"geo-check": geoCheckPipeline.handle,
"cleanup-orphaned": workerHelper.getCleanupOrphanedJob(),
"cleanup-retention": workerHelper.getCleanupRetentionJob(),
egress: workerHelper.getEgressRecoveryJob(),
};
const worker = await DBQueueWorker.create({
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,
workerId,
});
return { worker, networkService, bufferService, statusService };
const secretsRotationService = new SecretsRotationService(monitorsRepository, encryptionService, logger);
try {
const result = await secretsRotationService.run();
logger.debug({
message: "Secrets rotation run successfully",
service: "WorkerServices",
method: "buildWorker",
details: { ...result },
});
} catch (error: unknown) {
logger.warn({
message: `Could not rotate Docker TLS secrets: ${error instanceof Error ? error.message : String(error)}`,
service: "WorkerServices",
method: "buildWorker",
details: { ...(error instanceof AppError ? error.details : {}) },
});
}
return { worker, networkService, bufferService, statusService, egressService };
};
@@ -6,6 +6,7 @@ export interface ISettingsRepository {
findSingleton(): Promise<Settings | null>;
// update
update(settings: SettingsUpdate): Promise<Settings>;
removeEgressNotification(notificationId: string): Promise<void>; // $pull, so it cannot race a concurrent settings update
// delete
deleteLegacy: () => Promise<boolean>;
}
@@ -1,5 +1,6 @@
import { Schema, model, type Types } from "mongoose";
import type { Settings, SettingsThresholds } from "@/domain/app-settings/app-settings.type.js";
import { DEFAULT_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
interface AppSettingsDocument extends Omit<Settings, "id" | "createdAt" | "updatedAt"> {
_id: Types.ObjectId;
@@ -42,6 +43,9 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
globalThresholds: { type: thresholdsSchema },
globalProxyEnabled: { type: Boolean, default: false },
globalProxyId: { type: String, default: null },
egressCheckEnabled: { type: Boolean, default: false },
egressCheckTargets: { type: [String], default: () => [...DEFAULT_EGRESS_TARGETS] },
egressNotifications: { type: [String], default: () => [] }, // Notification ids, stored as strings like globalProxyId
},
{ timestamps: true }
);
@@ -3,6 +3,7 @@ import { ISettingsRepository } from "@/domain/app-settings/app-settings-reposito
import type { Settings, SettingsUpdate } from "@/domain/app-settings/app-settings.type.js";
import { AppSettingsModel, type AppSettingsDocument } from "@/domain/app-settings/app-settings.model.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { DEFAULT_EGRESS_TARGETS } from "@/domain/egress/egress.type.js";
class MongoSettingsRepository implements ISettingsRepository {
private toEntity = (doc: AppSettingsDocument): Settings => {
@@ -30,6 +31,9 @@ class MongoSettingsRepository implements ISettingsRepository {
globalThresholds: doc.globalThresholds ?? undefined,
globalProxyEnabled: doc.globalProxyEnabled ?? false,
globalProxyId: doc.globalProxyId ?? undefined,
egressCheckEnabled: doc.egressCheckEnabled ?? false,
egressCheckTargets: doc.egressCheckTargets ?? [...DEFAULT_EGRESS_TARGETS],
egressNotifications: doc.egressNotifications ?? [],
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -79,6 +83,10 @@ class MongoSettingsRepository implements ISettingsRepository {
return this.toEntity(updatedSettings);
};
removeEgressNotification = async (notificationId: string) => {
await AppSettingsModel.updateMany({ egressNotifications: notificationId }, { $pull: { egressNotifications: notificationId } });
};
deleteLegacy = async () => {
const res = await AppSettingsModel.deleteMany({ version: { $exists: false } });
return res.deletedCount > 0;
@@ -23,15 +23,27 @@ export interface ISettingsService {
getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>;
getCachedDBSettings(): Promise<Settings>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
removeEgressNotification(notificationId: string): Promise<void>;
}
const DB_SETTINGS_CACHE_TTL_MS = 60_000;
type CachedSettings = {
value: Promise<Settings>;
expiresAt: number;
};
export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig;
private settingsRepository: ISettingsRepository | null = null;
private dbSettingsCache: CachedSettings | null = null;
constructor(env: ValidatedEnv) {
constructor(
env: ValidatedEnv,
private dbSettingsTTLMs: number = DB_SETTINGS_CACHE_TTL_MS
) {
this.settings = {
jwtSecret: env.JWT_SECRET,
jwtTTL: env.TOKEN_TTL as StringValue,
@@ -75,7 +87,14 @@ export class SettingsService implements ISettingsService {
}
updateDbSettings = async (newSettings: SettingsUpdate) => {
return await this.getRepository().update(newSettings);
const updated = await this.getRepository().update(newSettings);
this.dbSettingsCache = null;
return updated;
};
removeEgressNotification = async (notificationId: string) => {
await this.getRepository().removeEgressNotification(notificationId);
this.dbSettingsCache = null;
};
getDBSettings = async () => {
@@ -95,4 +114,17 @@ export class SettingsService implements ISettingsService {
return settings;
};
getCachedDBSettings = async () => {
const hit = this.dbSettingsCache;
if (hit && hit.expiresAt > Date.now()) {
return hit.value;
}
const entry: CachedSettings = { value: this.getDBSettings(), expiresAt: Date.now() + this.dbSettingsTTLMs };
this.dbSettingsCache = entry;
entry.value.catch(() => {
if (this.dbSettingsCache === entry) this.dbSettingsCache = null;
});
return entry.value;
};
}
@@ -51,6 +51,10 @@ export interface Settings {
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
// Egress self-check (see domain/egress)
egressCheckEnabled: boolean;
egressCheckTargets: string[];
egressNotifications: string[]; // Notification ids to receive the "egress recovered" alert
createdAt: string;
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 } },
+6
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,
@@ -337,6 +338,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([
@@ -12,7 +12,6 @@ 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: {
@@ -55,10 +54,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;
+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;
}
@@ -0,0 +1,40 @@
import { Schema, model, type Types } from "mongoose";
import { EgressStatuses, type EgressProbeResult, type EgressState } from "@/domain/egress/egress.type.js";
interface EgressStateDocument extends Omit<EgressState, "id" | "degradedSince" | "lastRecoveredAt" | "lastProbeAt" | "createdAt" | "updatedAt"> {
_id: Types.ObjectId;
singleton: boolean;
degradedSince: Date | null;
lastRecoveredAt: Date | null;
lastProbeAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
const probeResultSchema = new Schema<EgressProbeResult>(
{
target: { type: String, required: true },
reachable: { type: Boolean, required: true },
responseTime: { type: Number, default: 0 },
message: { type: String },
},
{ _id: false }
);
const EgressStateSchema = new Schema<EgressStateDocument>(
{
singleton: { type: Boolean, required: true, unique: true, default: true },
status: { type: String, enum: EgressStatuses, required: true, default: "ok" },
degradedSince: { type: Date, default: null },
lastRecoveredAt: { type: Date, default: null },
lastProbeAt: { type: Date, default: null },
lastProbeResults: { type: [probeResultSchema], default: () => [] },
},
{ timestamps: true }
);
const EgressStateModel = model<EgressStateDocument>("EgressState", EgressStateSchema);
export type { EgressStateDocument };
export { EgressStateModel };
export default EgressStateModel;
@@ -0,0 +1,13 @@
import type { EgressProbeResult, EgressState } from "@/domain/egress/egress.type.js";
export interface IEgressStateRepository {
// read
findSingleton(): Promise<EgressState>; // creates the "ok" singleton on first call, a plain read after that
// update
recordProbe(results: EgressProbeResult[], now: Date): Promise<EgressState>;
reset(): Promise<EgressState>; // back to "ok" with no episode; used when the feature is toggled
// Atomic transitions. Each returns the updated state when this caller performed the transition,
// or null when the state was already on the target side (another worker got there first).
markDegraded(results: EgressProbeResult[], now: Date): Promise<EgressState | null>;
markRecovered(results: EgressProbeResult[], now: Date): Promise<EgressState | null>;
}
@@ -0,0 +1,88 @@
import { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
import type { EgressProbeResult, EgressState } from "@/domain/egress/egress.type.js";
import { EgressStateModel, type EgressStateDocument } from "@/domain/egress/egress-state.model.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
const SERVICE_NAME = "EgressStateRepository";
class MongoEgressStateRepository implements IEgressStateRepository {
static SERVICE_NAME = SERVICE_NAME;
private toEntity = (doc: EgressStateDocument): EgressState => {
return {
id: toStringId(doc._id),
status: doc.status,
degradedSince: doc.degradedSince ? toDateString(doc.degradedSince) : null,
lastRecoveredAt: doc.lastRecoveredAt ? toDateString(doc.lastRecoveredAt) : null,
lastProbeAt: doc.lastProbeAt ? toDateString(doc.lastProbeAt) : null,
lastProbeResults: (doc.lastProbeResults ?? []).map((result) => ({
target: result.target,
reachable: result.reachable,
responseTime: result.responseTime ?? 0,
...(result.message !== undefined && { message: result.message }),
})),
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
findSingleton = async () => {
// Read first: with timestamps on, every findOneAndUpdate would $set updatedAt, so a read would write.
const existing = await EgressStateModel.findOne({ singleton: true }).lean<EgressStateDocument>();
if (existing) return this.toEntity(existing);
try {
const doc = await EgressStateModel.findOneAndUpdate(
{ singleton: true },
{ $setOnInsert: { singleton: true, status: "ok" } },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).lean<EgressStateDocument>();
return this.toEntity(doc);
} catch (error: unknown) {
// Two processes can race the very first insert; the unique index rejects the loser, who reads the winner's row.
if ((error as { code?: number }).code === 11000) {
const doc = await EgressStateModel.findOne({ singleton: true }).lean<EgressStateDocument>();
if (doc) return this.toEntity(doc);
}
throw error;
}
};
reset = async () => {
const doc = await EgressStateModel.findOneAndUpdate(
{ singleton: true },
{ $set: { status: "ok", degradedSince: null, lastProbeAt: null, lastProbeResults: [] }, $setOnInsert: { singleton: true } },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).lean<EgressStateDocument>();
return this.toEntity(doc);
};
recordProbe = async (results: EgressProbeResult[], now: Date) => {
const doc = await EgressStateModel.findOneAndUpdate(
{ singleton: true },
{ $set: { lastProbeAt: now, lastProbeResults: results }, $setOnInsert: { singleton: true, status: "ok" } },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).lean<EgressStateDocument>();
return this.toEntity(doc);
};
markDegraded = async (results: EgressProbeResult[], now: Date) => {
// Only the caller that flips ok -> degraded sees a document back; everyone else sees null.
const doc = await EgressStateModel.findOneAndUpdate(
{ singleton: true, status: "ok" },
{ $set: { status: "degraded", degradedSince: now, lastProbeAt: now, lastProbeResults: results } },
{ new: true }
).lean<EgressStateDocument>();
return doc ? this.toEntity(doc) : null;
};
markRecovered = async (results: EgressProbeResult[], now: Date) => {
const doc = await EgressStateModel.findOneAndUpdate(
{ singleton: true, status: "degraded" },
{ $set: { status: "ok", lastRecoveredAt: now, lastProbeAt: now, lastProbeResults: results } },
{ new: true }
).lean<EgressStateDocument>();
return doc ? this.toEntity(doc) : null;
};
}
export default MongoEgressStateRepository;
@@ -0,0 +1,31 @@
import { IEgressStateRepository } from "@/domain/egress/egress-state.repository.interface.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import type { EgressState } from "@/domain/egress/egress.type.js";
export interface IEgressStateService {
getState(): Promise<EgressState>;
reset(): Promise<EgressState>;
}
const SERVICE_NAME = "EgressStateService";
// API-process view of the instance-global egress state. Probe-driven transitions are owned by the
// worker (egress.service.ts); this exists so the controller never touches the repositories.
export class EgressStateService implements IEgressStateService {
static SERVICE_NAME = SERVICE_NAME;
constructor(
private egressStateRepository: IEgressStateRepository,
private jobsRepository: IJobsRepository
) {}
getState = async (): Promise<EgressState> => {
return this.egressStateRepository.findSingleton();
};
// Called when the feature is switched on or off: drop any pending recovery job and start from "ok".
reset = async (): Promise<EgressState> => {
await this.jobsRepository.deleteGlobalJob("egress");
return this.egressStateRepository.reset();
};
}
+278
View File
@@ -0,0 +1,278 @@
import type { Monitor } from "@/domain/monitors/monitor.type.js";
import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
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 { ILogger } from "@/utils/logger.js";
import {
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 { parseEgressTarget, type EgressTarget } from "@/utils/egressTarget.js";
const SERVICE_NAME = "EgressService";
const PROBE_TIMEOUT_MS = 5000;
// How long a settled assessment answers for later failures. An assessment still in flight is shared
// for as long as it takes, so this only bounds how stale a reused answer can be.
const ASSESSMENT_REUSE_MS = 5000;
// Global job row (refId null) that re-probes while degraded. Inserted when egress degrades, removed on recovery.
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.
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";
const PROBE_TEAM_ID = "system";
export interface IEgressService {
probeTargets(targets: string[]): Promise<EgressProbeResult[]>;
// Called by the check producer when a check fails. Null when the feature is off or the assessment itself failed.
assessAfterFailure(): Promise<EgressStatus | null>;
// Handler for the "egress" job: re-probes while degraded and removes the job once egress is back.
checkRecovery(job: Job): Promise<void>;
}
type CachedAssessment = {
value: Promise<EgressAssessment | null>;
expiresAt: number | null; // null while the assessment is still in flight
};
export class EgressService implements IEgressService {
static SERVICE_NAME = SERVICE_NAME;
private assessment: CachedAssessment | null = null;
constructor(
private settingsService: ISettingsService,
private egressStateRepository: IEgressStateRepository,
private jobsRepository: IJobsRepository,
private networkService: INetworkService,
private proxyResolver: IProxyResolver,
private logger: ILogger
) {}
// ****************************
// Probing
// ****************************
// Reuses the existing ping/port/http providers by dressing each target up as a minimal monitor.
// proxyMode "inherit" lets the resolver apply the global proxy to http probes, as it does for monitors.
private toProbeMonitor = (target: EgressTarget, raw: string): Monitor => {
const base = {
id: PROBE_MONITOR_ID,
teamId: PROBE_TEAM_ID,
name: `Egress probe: ${raw}`,
method: "GET",
proxyMode: "inherit",
useAdvancedMatching: false,
ignoreTlsErrors: false,
customUpCodes: [],
};
switch (target.kind) {
case "http":
return { ...base, type: "http", url: target.url } as unknown as Monitor;
case "port":
return { ...base, type: "port", url: target.host, port: target.port } as unknown as Monitor;
case "ping":
return { ...base, type: "ping", url: target.host } as unknown as Monitor;
}
};
private probeTarget = async (raw: string): Promise<EgressProbeResult> => {
const target = parseEgressTarget(raw);
if (!target) {
// Validation rejects these; a stored value can still be stale, and it must not count as reachable.
return { target: raw, reachable: false, responseTime: 0, message: "Invalid egress target" };
}
const monitor = this.toProbeMonitor(target, raw);
let timer: NodeJS.Timeout | undefined;
const { response, responseTime, error } = await timeRequest(async () => {
const proxyUrl = await this.proxyResolver.resolve(monitor);
return Promise.race([
this.networkService.requestStatus(monitor, { proxyUrl }),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Egress probe timed out after ${PROBE_TIMEOUT_MS}ms`)), PROBE_TIMEOUT_MS);
}),
]).finally(() => clearTimeout(timer));
});
if (error || !response) {
return {
target: raw,
reachable: false,
responseTime,
message: error instanceof Error ? error.message : error ? String(error) : "No response",
};
}
return {
target: raw,
// Any HTTP response, including 4xx/5xx, proves the instance can reach the target; only a transport
// failure counts against egress. Ping and port providers have no such distinction.
reachable: response.status === true || (target.kind === "http" && isHttpStatusCode(response.code)),
responseTime: response.responseTime ?? responseTime,
message: response.message,
};
};
probeTargets = async (targets: string[]): Promise<EgressProbeResult[]> => {
return await Promise.all(targets.map((target) => this.probeTarget(target)));
};
private resolveTargets = (configured: string[] | undefined): string[] => {
const targets = (configured ?? []).map((target) => target.trim()).filter((target) => target.length > 0);
// An empty list would make "all unreachable" vacuously true, so fall back to the defaults.
return targets.length > 0 ? targets : [...DEFAULT_EGRESS_TARGETS];
};
// ****************************
// Event-triggered assessment
// ****************************
assessAfterFailure = async (): Promise<EgressStatus | null> => {
const assessment = await this.sharedAssessment();
return assessment === "disabled" ? null : assessment;
};
// When egress is down every monitor fails at once. Concurrent failures share the in-flight assessment
// however long it takes, and a settled one answers for ASSESSMENT_REUSE_MS. Same shape as ProxyResolver's cache.
private sharedAssessment = (): Promise<EgressAssessment | null> => {
const cached = this.assessment;
if (cached && (cached.expiresAt === null || cached.expiresAt > Date.now())) {
return cached.value;
}
const entry: CachedAssessment = { value: this.assess(), expiresAt: null };
this.assessment = entry;
void entry.value.then((result) => {
if (this.assessment !== entry) return;
if (result === null) {
this.assessment = null; // an internal failure is not cached, so the next failing check retries
} else {
entry.expiresAt = Date.now() + ASSESSMENT_REUSE_MS;
}
});
return entry.value;
};
private assess = async (): Promise<EgressAssessment | null> => {
try {
const settings = await this.settingsService.getCachedDBSettings();
if (!settings.egressCheckEnabled) {
return "disabled";
}
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(false);
return "degraded";
}
const results = await this.probeTargets(this.resolveTargets(settings.egressCheckTargets));
const now = new Date();
if (results.some((result) => result.reachable)) {
await this.egressStateRepository.recordProbe(results, now);
return "ok";
}
// 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(true);
const degraded = await this.egressStateRepository.markDegraded(results, now);
this.logger.warn({
message: degraded
? "Instance egress degraded: every reliability target is unreachable. Monitor failures will not be counted until it recovers"
: "Instance egress degraded (transition already recorded by another worker)",
service: SERVICE_NAME,
method: "assessAfterFailure",
details: { results },
});
return "degraded";
} catch (error: unknown) {
// The egress check must never break check production; treat an internal failure as "unknown" and carry on.
this.logger.error({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: "assessAfterFailure",
stack: error instanceof Error ? error.stack : undefined,
});
return null;
}
};
// ****************************
// Recovery job
// ****************************
// 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 (reschedule: boolean) => {
const intervalMs = EGRESS_RECOVERY_POLL_SECONDS * 1000;
const seed: JobSeed = {
id: RECOVERY_JOB_ID,
type: RECOVERY_JOB_TYPE,
refId: null,
isActive: true,
nextScheduledAt: Date.now() + intervalMs,
intervalMs,
};
if (reschedule) {
await this.jobsRepository.upsertCleanupJob(seed); // global-row upsert that also sets the schedule
} else {
await this.jobsRepository.upsertJob(seed);
}
};
// Removes the row only if nobody has rescheduled it since this run claimed it. Another worker may have seen
// egress fail again in the meantime and started a new episode; its schedule must survive.
private releaseRecoveryJob = async (job: Job) => {
await this.jobsRepository.deleteGlobalJobIfUnchanged(RECOVERY_JOB_TYPE, job.nextScheduledAt);
};
// Runs on the queue at the configured interval while degraded. Errors propagate so the queue records
// the failure and retries with its usual backoff.
checkRecovery = async (job: Job): Promise<void> => {
const settings = await this.settingsService.getCachedDBSettings();
const state = await this.egressStateRepository.findSingleton();
if (!settings.egressCheckEnabled || state.status !== "degraded") {
// Feature switched off, recovery recorded by another worker, or state reset: nothing left to poll for.
await this.releaseRecoveryJob(job);
return;
}
const results = await this.probeTargets(this.resolveTargets(settings.egressCheckTargets));
const now = new Date();
if (!results.some((result) => result.reachable)) {
await this.egressStateRepository.recordProbe(results, now);
return;
}
const recovered = await this.egressStateRepository.markRecovered(results, now);
await this.releaseRecoveryJob(job);
if (!recovered) {
// Another process performed the transition.
return;
}
this.logger.info({
message: `Instance egress recovered (degraded since ${recovered.degradedSince ?? "unknown"})`,
service: SERVICE_NAME,
method: "checkRecovery",
details: { results },
});
};
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more