Compare commits

...
Author SHA1 Message Date
Alex Holliday e81fdb9810 remove unused hook 2026-07-31 11:50:02 -07:00
Alex Holliday 9712579c4d remove unused imports 2026-07-31 11:45:41 -07:00
Alex Holliday 98e0ffc983 update tooltips 2026-07-31 11:41:18 -07:00
Alex Holliday ac251c4d3f update tests 2026-07-31 11:36:20 -07:00
Alex Holliday 08f167c18e cap values at p95 on client 2026-07-31 11:36:10 -07:00
Alex Holliday 8a608961e6 remove server side normalization 2026-07-31 10:31:39 -07:00
Alexander Holliday 093553f9ba Merge pull request #3818 from bluewave-labs/feat/chart-cleanup
feat: chart cleanup
2026-07-30 12:16:24 -07:00
Alex Holliday e5282b9099 opacity -> 0.2 2026-07-30 12:04:25 -07:00
Alex Holliday 6b4444038c add tests 2026-07-30 11:54:19 -07:00
Alex Holliday 89464e9be4 add legend 2026-07-30 11:54:12 -07:00
Alex Holliday 6856914d41 extract hardcoded strings 2026-07-30 10:56:24 -07:00
Alex Holliday c102cd8d26 openapi schemas 2026-07-30 10:35:01 -07:00
Alexander Holliday ff28e74aca Merge pull request #3797 from LvckyAPI/fix-incorrect-status
Fix monitoring status stuck
2026-07-30 09:44:15 -07:00
Alexander Holliday 23b420d072 Merge pull request #3815 from bluewave-labs/feat/stacked-timing-chart
feat: stacked timing chart
2026-07-29 12:42:50 -07:00
Alex Holliday 0172272dc3 update tests 2026-07-29 12:33:56 -07:00
Alex Holliday 3f088c70f8 universal ymax cap 2026-07-29 12:30:36 -07:00
Alex Holliday ed54fa4668 first byte to total repsonse 2026-07-29 12:30:18 -07:00
Alex Holliday 0e0cace676 merge charts 2026-07-29 11:45:49 -07:00
Alex Holliday 58f1ae7fe9 extract xtick 2026-07-29 11:04:11 -07:00
Alex Holliday e3075318b6 denormalize resposne times 2026-07-29 10:57:20 -07:00
Alex Holliday 81aab35961 conditionally render charts 2026-07-28 15:27:19 -07:00
Alex Holliday 4e495eed33 add chart colors to theme 2026-07-28 15:27:04 -07:00
Alex Holliday 061da5e7ef coerce values to 0 if null 2026-07-28 15:26:52 -07:00
Alex Holliday c7662727e7 add area charts 2026-07-28 11:24:23 -07:00
Alex Holliday 13c921531c update types, add skeleton for stacked timing phase chart 2026-07-28 11:13:50 -07:00
Alex Holliday 0cf8cfa5dc add avg timing phases to details response 2026-07-28 11:13:35 -07:00
Alex Holliday 4182a964a6 minor version bump 2026-07-28 09:48:31 -07:00
Iven Schlenther 04f7ef252a Fix indentation in statusService.ts for isUnknownStatus assignment. 2026-07-27 15:07:00 +02:00
Iven Schlenther 3201653f19 Fix monitor status handling: replace isKnownStatus with isUnknownStatus for clarity and simplify initialization logic. 2026-07-27 15:03:43 +02:00
Iven Schlenther 0988a61c1d Resolve legacy monitor status issues: handle unknown statuses, fix stuck states from old boolean values, and add regression tests. Adjust BaseChart text color for theme compatibility. 2026-07-27 15:03:41 +02:00
Alexander Holliday 80ea09b864 Merge pull request #3801 from bluewave-labs/chore/cleanup
chore: cleanup
2026-07-24 12:30:08 -07:00
Alex Holliday e0228918fb fix tests 2026-07-24 12:28:45 -07:00
Alex Holliday 8369db5ca2 remove logger 2026-07-24 11:38:08 -07:00
Alex Holliday 49e8d26bfc remove legacy try/catch blocks 2026-07-24 11:34:54 -07:00
Alex Holliday c28be97fb0 update tests 2026-07-24 11:32:18 -07:00
Alex Holliday ed36db172a dedupe hardware stat definitions, fix tests, remove misleading null return types 2026-07-24 11:32:00 -07:00
Alex Holliday c021d04f53 extract swtich 2026-07-24 11:09:53 -07:00
Alex Holliday 86eeda3f0e replace error instances with AppError 2026-07-24 10:49:39 -07:00
Alex Holliday 25d81f4238 remove dead findByMonitorIdAndDateRange method 2026-07-24 10:43:56 -07:00
Alexander Holliday af8ee643e1 Merge pull request #3800 from bluewave-labs/chore/monitor-domain-refactor
extact monitor children deletion to helper
2026-07-23 11:25:12 -07:00
Alex Holliday 61e442493a extact monitor children deletion to helper 2026-07-23 10:49:59 -07:00
Alexander Holliday 9540084425 Merge pull request #3799 from bluewave-labs/chore/domain-refactoring
chore: domain refactoring
2026-07-23 10:30:33 -07:00
Alex Holliday f50ba1bf23 update tests 2026-07-23 10:26:01 -07:00
Alex Holliday b4e42bfd68 add type check script 2026-07-23 10:25:51 -07:00
Alex Holliday 3af00281c4 extract date range calculation for conisistency 2026-07-23 10:25:24 -07:00
Alex Holliday 4a90ab8e78 extract pagination 2026-07-23 09:36:06 -07:00
Alexander Holliday 7598cf3007 Merge pull request #3798 from bluewave-labs/chore/api-cleanup
chore: api cleanup
2026-07-22 11:03:07 -07:00
Alex Holliday e1c89cd093 dedupe normalization 2026-07-22 11:00:59 -07:00
Alex Holliday 80d4e818f1 remove unused static vars 2026-07-22 10:36:16 -07:00
Alex Holliday 4949acac1c remove unused email templates, remove duplicate constants 2026-07-22 10:26:00 -07:00
Alex Holliday 586e79efb2 remove unused getters 2026-07-22 10:13:37 -07:00
Alex Holliday c961f5292d fix signature, add tests 2026-07-22 10:05:36 -07:00
Alex Holliday d53785b768 remove unused utilities, fix delete profile image issue 2026-07-22 10:03:48 -07:00
Alexander Holliday b9cbf199e6 Merge pull request #3796 from LvckyAPI/develop
Fixes UI bug with German translations
2026-07-22 09:44:19 -07:00
Iven Schlenther ac6678a986 Update English and German translations for "temp" label to "CPU temperature" in locales files. 2026-07-22 11:33:06 +02:00
Iven Schlenther ed1ef56184 - Add sx={{ overflowWrap: "anywhere" }} to Typography in BaseChart for improved text wrapping.
- Adjust `alignItems` to `stretch` and add `maxWidth` (260) to Gauge components in Infrastructure Details page.
- Update German translations for memory titles, temperatures, and labels in locales file.
2026-07-22 11:27:38 +02:00
Alexander Holliday ac16126e2e Merge pull request #3795 from bluewave-labs/fix/enums
fix: remove inline enum definitions
2026-07-21 10:53:52 -07:00
Alex Holliday 8e600e9a87 remove inline enum definitions 2026-07-21 10:48:18 -07:00
Alexander Holliday 790d83b7ed Merge pull request #3794 from bluewave-labs/fix/positional-args
fix: positional args
2026-07-21 10:30:29 -07:00
Alex Holliday c567e4be5e Update tests 2026-07-21 10:26:10 -07:00
Alex Holliday 936d9cd9a0 update call site 2026-07-21 10:26:02 -07:00
Alex Holliday 6e047d2cdb convert positional args to config obj 2026-07-21 10:25:50 -07:00
Alex Holliday aad03b787f update tests 2026-07-21 10:07:18 -07:00
Alex Holliday bb374db463 remove stray import 2026-07-21 10:07:09 -07:00
Alex Holliday 57e008c7ba update services config 2026-07-21 10:05:17 -07:00
Alex Holliday 5d3dc03c89 add a provider registry to notificaiton service, replace positional args and switch statements 2026-07-21 10:02:20 -07:00
Alexander Holliday bf255a0178 Merge pull request #3793 from bluewave-labs/chore/refactor-routes
chore: convert router classes to exported functions
2026-07-20 11:30:41 -07:00
Alex Holliday 36434c1160 convert router classes to exported functions 2026-07-20 11:25:39 -07:00
Alexander Holliday 63cecf3447 Merge pull request #3792 from bluewave-labs/chore/purge-team/groups
purge unused groups route, update tests
2026-07-20 10:26:45 -07:00
Alex Holliday 4d9e5821a8 purge unused groups route, update tests 2026-07-20 10:21:43 -07:00
Alexander Holliday 5433a0cf5f Merge pull request #3791 from bluewave-labs/chore/fix-access-modifier
chore: protected -> private as no class inherits
2026-07-20 10:06:08 -07:00
Alex Holliday d485adbb54 narrow mapDocument types 2026-07-20 10:04:55 -07:00
Alex Holliday 3eadb99c5e protected -> private as no class inherits 2026-07-20 10:01:48 -07:00
Alexander Holliday 9c04061a04 Merge pull request #3787 from jpomykala/develop
Add Polish language
2026-07-20 09:34:54 -07:00
Alexander Holliday 897ffd5736 Merge pull request #3788 from Osamaali313/fix/response-time-poor-label
Fix missing "Poor" label on the average response-time gauge
2026-07-20 09:34:16 -07:00
Osamaali313 368595178d Fix missing "Poor" label on the average response-time gauge
`RadialAvgResponse` maps the response-time palette to a qualitative word:

    const palette = getResponseTimeColor(avg);
    const msg: Record<string, string> = {
        success: "Excellent",
        warning: "Average",
        danger: "Poor",
    };
    ...
    {msg[palette]}

But `getResponseTimeColor` only ever returns `"success" | "warning" | "error"`
(MonitorUtils.ts) — never `"danger"`. So for the slow case `palette` is
`"error"`, `msg["error"]` is `undefined`, and no label renders. The same
component uses `theme.palette[palette].main`, which works precisely because
`palette` is the real MUI key `"error"`; every other palette helper in
MonitorUtils.ts also uses `"error"`, and `"danger"` appears nowhere else in
the client. Any monitor whose average response time is >= 300 ms shows an
empty label exactly where the "Poor" signal matters most. Use the `error`
key.
2026-07-18 23:36:43 +03:00
Jakub Pomykała 924c862244 Format pl.json 2026-07-18 22:13:05 +02:00
Jakub Pomykała cf451416b1 Update polish translations after review 2026-07-18 21:33:10 +02:00
Jakub Pomykała 6e2a64fc9f Add Polish to language selector 2026-07-18 20:57:50 +02:00
Jakub Pomykała 0e52cb6231 Add polish 2026-07-18 20:22:00 +02:00
Alexander Holliday 507b96164f Merge pull request #3784 from bluewave-labs/chore/refactor-utils
chore: refactor utils
2026-07-15 10:24:51 -07:00
Alex Holliday 887521c376 Merge branch 'develop' into chore/refactor-utils 2026-07-15 10:24:23 -07:00
Alexander Holliday caee9510eb Merge pull request #3783 from bluewave-labs/chore/controller-refactor
chore: add catchAsync to handle errors, remove all try/catch blocks from con…
2026-07-15 10:23:48 -07:00
Alex Holliday 825a81d326 extract shared mongo mapper utils 2026-07-15 10:20:16 -07:00
Alex Holliday bd13e9d9c8 add catchAsync to handle errors, remove all try/catch blocks from contorllers 2026-07-15 10:02:36 -07:00
Alexander Holliday 92696d3685 Merge pull request #3782 from bluewave-labs/chore/hardware-aggregation-refactor
chore: hardware aggregation refactor
2026-07-15 09:59:03 -07:00
Alex Holliday 5120390923 aggregation->aggregations 2026-07-15 09:23:16 -07:00
Alex Holliday 0810fcbb88 refactor hardware aggregation pipeline 2026-07-15 09:18:35 -07:00
Alexander Holliday 15453c2d8d Merge pull request #3781 from bluewave-labs/chore/monitor-service-refactor
chore: remove duplicated code
2026-07-14 10:48:53 -07:00
Alex Holliday 70f10a73d8 remove duplicated code 2026-07-14 10:46:25 -07:00
Alexander Holliday 930c06f5ff Merge pull request #3780 from bluewave-labs/chore/dedupe
chore: remove toEntityWithChecks
2026-07-14 10:37:14 -07:00
Alex Holliday b088a8c878 remove toEntityWithChecks 2026-07-14 10:17:38 -07:00
Alexander Holliday 067a186765 Merge pull request #3779 from bluewave-labs/fix/cascade-incidents
fix: delete incidents on all monitors deleted
2026-07-14 09:26:13 -07:00
Alex Holliday 107b5cbd96 delete incidents on all monitors deleted 2026-07-14 09:24:14 -07:00
Alexander Holliday 7fdd51c072 Merge pull request #3778 from edobusy/fix/readme-k8s-install-link
fix(docs): correct K8s install link path in translated READMEs
2026-07-14 09:10:10 -07:00
Alexander Holliday 2331035da0 Merge pull request #3773 from bluewave-labs/fix/status-page-brand-color-not-applied
fix(status page): apply brand color via a dedicated brand token
2026-07-14 09:04:06 -07:00
edobusy fd4edb7227 fix(docs): correct K8s install link path in translated READMEs
The translated READMEs in docs/translations/ link to the Kubernetes
installation guide with a path copied from the root README
(./charts/helm/checkmate/INSTALLATION.md). Because these files live two
levels below the repo root, that path resolves to
docs/translations/charts/..., which does not exist, so the link is
broken in all 17 translations.

Fix the path to ../../charts/helm/checkmate/INSTALLATION.md so it
resolves to the repo-root charts/ directory. The root README.md is
correct as-is and is left unchanged.
2026-07-14 13:43:37 +10:00
Alexander Holliday 538b0df7e9 Merge pull request #3777 from bluewave-labs/fix/shield-csp
add img.shields.io to csp directive
2026-07-13 10:11:24 -07:00
Alex Holliday 9f79feec8e add img.shields.io to csp directive 2026-07-13 10:10:28 -07:00
Alexander Holliday fe703199f5 Merge pull request #3776 from bluewave-labs/chore/simplify-deployment
chore: simplify deployment
2026-07-13 09:57:13 -07:00
Alex Holliday 800080fd06 add log warnings about legacy env vars 2026-07-13 09:48:02 -07:00
Alex Holliday c1bcd9b206 add note to helm chart about migration 2026-07-13 09:47:45 -07:00
Alex Holliday 5d55750b7b update docs 2026-07-13 09:38:55 -07:00
Alexander Holliday 4f398e98d3 Merge pull request #3774 from bluewave-labs/feat/italian-translation-and-contrib-docs
feat(i18n): add Italian translation, document translation workflow
2026-07-13 09:05:01 -07:00
gorkem-bwl 242e853bf0 i18n(it): review pass — fix grammar, awkward phrasing, inconsistencies
91 leaves refined after a native-Italian style pass.

## Categories of fixes

- Grammar / gender agreement — e.g. `logs.table.headers.lastSeenAt`
  "Ultima visto il" → "Visto l'ultima volta"; heap gauge subject
  agreement in `logs.diagnostics.gauges.allocatedPercentage`.
- Awkward phrasing / calques — `notFound.subtitle` drops stilted
  "ad esso"; `incidents.fallback.title` "in salute" → "funzionano
  correttamente" (was reading as if for a person, not systems);
  `settings.form.removeMonitors.title` "Reset del sistema" →
  "Reimposta il sistema"; "con successo" → "correttamente" on
  status-page delete confirmations.
- Consistency — paused monitor status unified on "In pausa" across
  `common.labels.paused`, monitor status labels, status-bar keys, and
  the `bulkPause.*_one/other` copies. Button verbs
  "Sospendi"/"Riprendi" kept for brevity; status noun stays "In pausa".
- Mistranslations — `buttons.testNotifications` "Testa" → "Prova";
  `buttons.toggleTheme` "Cambia" → "Alterna"; Twilio "Auth token" now
  translated; Mongoose `readyState` clarified to "Stato di connessione".
- Register — trimmed "Ancora" from several empty-state titles so they
  read like natural Italian empty states.

## Verification

874 leaves in EN and IT, 0 missing / 0 extra, all placeholders and
inline tags preserved, no empty values.
2026-07-13 16:18:24 +03:00
gorkem-bwl 523bf2253c feat(i18n): add Italian translation, document translation workflow
## Changes

- Add `client/src/locales/it.json` — Italian translation of all 874 UI
  keys, structure mirrors `en.json` exactly. Placeholders (`{{value}}`,
  etc.) and inline tag tokens (`<settingsLink>`, `<highlight>`, ...)
  are preserved. Product/brand names, technical abbreviations, and
  placeholder-style examples (e.g. `you@example.com`) are kept in
  English. 116 leaves are intentionally kept verbatim in English
  (brand names, protocol names, sample values, port numbers, regex).
- Register `it: "Italiano"` in `LanguageSelector`'s `languageNames`
  map so the dropdown shows the localised name; the selector already
  picks up any locale present in the i18n resources.
- Fill in the two "How do I..." sections that CONTRIBUTING.md's table
  of contents already links to but did not have bodies:
  - "Improve the documentation?" — short guidance on doc PRs and
    keeping README translations in sync.
  - "Help with translations?" — describes the current per-language
    JSON workflow (source of truth: `en.json`; improve an existing
    language by editing its JSON; add a new one by adding a JSON file
    and registering the display name in `LanguageSelector`). Notes
    that the older PoEditor sync workflow is no longer authoritative.

## Benefits

- Italian speakers can now use Checkmate in Italian.
- Contributors who follow the "Help with translations?" link from
  the CONTRIBUTING TOC land on real, current guidance instead of a
  broken anchor.
- Translation quality can be improved incrementally by anyone
  editing `it.json` directly; no external tooling required.
2026-07-13 16:10:04 +03:00
gorkem-bwl f92dc835b7 fix(status page): apply brand color via a dedicated brand token
The status page appearance form saves a customer-picked brand color
(`statusPage.color`), but `StatusPageThemeProvider` never consumed it —
every rendered surface styled from `tokens.up`, so the picker had no
visible effect on the public page.

Rather than overriding `tokens.up` (the semantic OK/up status color,
which would let a red or orange brand color render the "all
operational" indicator in a red that reads as downtime), this
introduces a separate `brand` / `brandStrong` / `brandSoft` token
triple that is distinct from the up/OK color.

## Changes

- `themes/tokens.ts`: add optional `brand` / `brandStrong` / `brandSoft`
  fields on `StatusPageThemeTokens`. Themes leave them undefined; the
  provider falls back to the theme's `up*` values so nothing regresses
  before individual themes opt in.
- `themes/StatusPageThemeProvider.tsx`: add `brandColor` prop and a
  `resolveBrandTokens` helper. When a valid hex is provided it feeds
  `brand`, `brandStrong = darken(x, 0.2)`, `brandSoft = alpha(x, 0.15)`
  using MUI's shared `alpha`/`darken` (replaces the ad-hoc hex helpers).
  Invalid or empty values fall through cleanly.
- `Status/index.tsx`: pass `brandColor={statusPage.color}` only at the
  public route. The admin preview provider wraps an iframe whose
  document renders itself, so passing the prop there had no effect and
  is dropped.
- `themes/refined/styles.ts`: switch accent surfaces (hero logo, active
  chart-switch button, hardware pill, footer link) from `tokens.up` to
  `tokens.brand`. Uptime bars, status pills, and OK indicators stay on
  `tokens.up`. Other themes keep their current styling until a designer
  decides which surfaces should switch.

## Benefits

- Brand Color picker actually changes the rendered accent surfaces.
- The semantic "OK/up" status color is never repainted by the brand
  color, so operational-state indicators stay green regardless of the
  customer's brand.
- Reuses MUI's `alpha` / `darken` helpers already used across the
  client; no divergent color utilities.
- Small, incremental adoption: themes opt in at their own pace by
  reading `tokens.brand*` where they want the brand accent.
2026-07-13 15:52:12 +03:00
Alexander Holliday 0b0d412187 Merge pull request #3766 from bluewave-labs/fix/network-notice-loopback-detection
fix(infrastructure): trigger network 'no traffic' notice on interface name only
2026-07-10 19:50:02 -07:00
gorkem-bwl 77b817778b address review feedback: rename notice to match new trigger
The notice now triggers on interface names (loopback / container-bridge
only), not on zero traffic. Update the variable, i18n key, and English
copy to match:

- showNoTrafficNotice -> showLoopbackOnlyNotice
- pages.infrastructure.charts.labels.netNoTraffic ->
  pages.infrastructure.charts.labels.loopbackOnlyNotice
- English string rewritten to describe the loopback-only condition
  rather than "zero traffic on every interface"

Other locales still hold the old string under the old key and will fall
back to English until the translation pipeline picks up the new key.
Tracked separately.
2026-07-11 04:29:22 +03:00
Alexander Holliday eddb9a82a8 Merge pull request #3764 from bluewave-labs/fix/increase-json-body-limit
fix(server): raise express.json body limit to 10mb
2026-07-10 18:23:44 -07:00
gorkem-bwl 9459edce00 scope 10mb body limit to /monitors/import/json only
Address review feedback: keep the global express.json() at the default
100kb and mount a route-scoped 10mb parser in front of it for
/api/v1/monitors/import/json. Express matches middleware in registration
order, so the import route uses the larger parser and every other
endpoint keeps the tight default. No change in DoS surface for normal
traffic.
2026-07-11 04:22:42 +03:00
Alexander Holliday eaf1064413 Merge pull request #3763 from bluewave-labs/fix/status-page-custom-css-injection
fix(status-page): inject sanitized customCSS on the public page
2026-07-10 09:51:10 -07:00
Alexander Holliday dae6f9a570 Merge pull request #3769 from edobusy/docs/readme-italian
docs(readme): add Italian translation
2026-07-10 09:20:18 -07:00
edobusy 9fa45c5871 docs(readme): add Italian translation 2026-07-10 23:46:49 +10:00
gorkem-bwl 2810cd3562 fix(infrastructure): trigger network "no traffic" notice on interface name only
The notice at the top of the network charts was firing whenever the aggregated
per-second delta was zero across all interfaces. That is a false positive when
Capture is running on a real host but the polling interval is >= 1 minute and
the aggregation bucket is also 1 minute — first == last in the bucket, delta
is zero, but the interface has plenty of real traffic in its cumulative
counters.

Base the notice on interface names instead: only show it when the ONLY
interfaces Capture reports are loopback / container-bridge names (lo, lo0,
docker0). Real NICs (enp0s6, eth0, wlan0, tunN, vethN, brN, etc.) keep the
notice hidden even when the aggregation currently reports zero throughput.

The underlying zero-throughput bug (single-sample buckets producing zero
deltas) is tracked separately.
2026-07-10 03:16:42 +03:00
gorkem-bwl f4177baed5 fix(server): raise express.json body limit to 10mb
Express defaults to a 100kb JSON body, which is too small for realistic
imports through POST /api/v1/monitors/import/json (backups with many
monitors and their config easily exceed 100kb, returning
"request entity too large").

Raise the parser cap to 10mb. That covers real-world import payloads
while keeping the DoS surface bounded — a larger, streaming file-upload
route for imports remains desirable as a follow-up.
2026-07-10 00:06:31 +03:00
gorkem-bwl 5feb666bdb fix(status-page): inject sanitized customCSS on the public page
The customCSS field on status pages was persisted and validated
end-to-end but never rendered on the public page, so operator-supplied
styles had no effect.

Inject the string as a <style> tag on the public route (which the
admin preview iframe also renders), reusing the existing
cssReferencesExternalResource check so external-resource CSS
(@import, url(http…), //…) is dropped defensively on the client.

The admin preview iframe already points at the public URL, so this
one insertion covers both the live page and the in-app preview.
2026-07-09 21:55:25 +03:00
Alex Holliday ceacaa5499 scope workflows 2026-07-09 11:24:35 -07:00
Alex Holliday 91343f996b hosting docs 2026-07-09 10:55:04 -07:00
Alex Holliday 312961e8c9 use repo owner 2026-07-09 10:27:56 -07:00
Alex Holliday 0475f194e9 temporary branch 2026-07-09 10:24:06 -07:00
Alex Holliday bbb051406f udpate docs 2026-07-09 10:21:09 -07:00
Alex Holliday 40a7599388 update workflows 2026-07-09 10:20:24 -07:00
Alex Holliday dffce2709e clean up docker files 2026-07-09 10:19:38 -07:00
Alex Holliday 62031a0ab1 add a CLIENT_CONFIG to the client, serve from BE with env vars 2026-07-09 09:49:45 -07:00
Alex Holliday ef175950b2 add dockerfile and build-image workflow 2026-07-09 09:29:31 -07:00
Alex Holliday 6d91efecad default client to same-origin /api/v1 and window.location.origin, drop UPTIME_APP_* placeholders 2026-07-08 11:04:47 -07:00
Alex Holliday f7d3bcb696 add root docker-compose 2026-07-08 10:47:03 -07:00
Alex Holliday 4a093e2b97 start health server on primary process true 2026-07-08 10:46:34 -07:00
Alexander Holliday 8396f478c5 Merge pull request #3760 from bluewave-labs/fix/status-page-monitor-leak
node:20 => node:22
2026-07-07 10:17:31 -07:00
Alex Holliday bbfd963deb node:20 => node:22 2026-07-07 10:16:56 -07:00
Alexander Holliday fc0cda3da6 Merge pull request #3758 from bluewave-labs/fix/status-page-monitor-leak
fix: status page monitor leak
2026-07-07 09:59:28 -07:00
Alex Holliday 861d867a17 run formatter 2026-07-07 09:55:21 -07:00
Alex Holliday 8d7c322279 bump version and deps 2026-07-07 09:54:21 -07:00
Alex Holliday 68a9bece1e bump dependencies 2026-07-07 09:48:34 -07:00
Alex Holliday 4f82a11c66 update tests 2026-07-07 09:47:41 -07:00
Alex Holliday cc1814f507 whitelist monitor properties instead of blacklist for status pages 2026-07-07 09:47:29 -07:00
Alex Holliday 436c11bcba Merge branch 'develop' into fix/status-page-monitor-leak 2026-07-07 09:02:48 -07:00
Alexander Holliday d5ec2936ad Merge commit from fork
Advisory fix 1
2026-07-06 09:46:15 -07:00
Alex 937ffe4f3b add and update tests 2026-07-06 09:39:59 -07:00
Alex 0df71d6356 use re2 for regex 2026-07-06 09:39:43 -07:00
Alex adba25269c tighten regex validation 2026-07-06 09:39:28 -07:00
Alex 386a384ef6 add RE2 dependency 2026-07-06 09:39:11 -07:00
Alex Holliday 8bf5db0834 remove dead code and tests 2026-07-03 11:28:00 -07:00
Alexander Holliday 817d01c857 Merge pull request #3754 from bluewave-labs/fix/worker-crash-on-mongo-error
fix: worker crash on mongo error
2026-07-03 10:50:59 -07:00
Alex Holliday 3f30acdbdf add tests 2026-07-03 10:45:53 -07:00
Alex Holliday 80b604b9ff add global unhandled exception handler 2026-07-03 10:34:02 -07:00
Alex Holliday 9356b0d482 catch on recordFailure, add defenisve cathc to run job 2026-07-03 10:33:45 -07:00
Alexander Holliday 6699a83948 Merge pull request #3753 from bluewave-labs/chore/worker-deregistration-tests
add additional worker tests
2026-07-03 09:48:18 -07:00
Alex Holliday 900c039672 add additional worker tests 2026-07-03 09:41:18 -07:00
Alex a4ab7d662e bump version fix 2026-07-02 11:05:30 -07:00
Alex 807d7e10c3 type bulk op 2026-07-02 10:55:57 -07:00
Alex 39043f3945 migration fix 2026-07-02 10:43:22 -07:00
Alex 5a72e1e123 Merge branch 'develop' of github.com:bluewave-labs/Checkmate into develop 2026-07-02 10:30:05 -07:00
Alex 959b150ebf fix migratin 2026-07-02 10:29:15 -07:00
Alexander Holliday 552b7a037d Merge pull request #3743 from bluewave-labs/feat/helm
feat: Update helm charts
2026-07-02 10:13:28 -07:00
Alex 8671685cf9 bump version 2026-07-02 10:07:42 -07:00
Alex ff536f40a2 use version tag 2026-07-02 09:44:37 -07:00
Alex e3d63a1cb4 bump version 2026-07-02 09:11:26 -07:00
Alexander Holliday 105d7f0580 Merge pull request #3723 from Buco7854/feat/customcss-status-page
Feat/customcss status page
2026-07-02 09:11:02 -07:00
Alexander Holliday e2a1f02971 Merge pull request #3744 from Harshitbro-ctrlv/feat/status-page-minimal-theme
feat(status-page): add minimal theme
2026-07-02 08:59:55 -07:00
Alex b7f548ff15 bump version 2026-07-02 08:49:36 -07:00
Harshitbro-ctrlv 29d05c1e57 feat(status-page): add minimal theme 2026-07-02 12:08:17 +05:30
Alex Holliday 0ec77a5ab1 add alias for backward compat 2026-06-30 16:58:52 -07:00
Alexander Holliday 95eb75f416 Merge pull request #3741 from sanmaxdev/fix/maintenance-window-monitor-edits
test: cover maintenance window monitor edits
2026-06-30 16:46:40 -07:00
Alex Holliday 8f657dcd36 fix mongo namespace 2026-06-30 10:51:21 -07:00
Alex Holliday c0e33eb4fa skip general rate limiter on dev mode 2026-06-30 09:08:20 -07:00
Alex Holliday 754b0af3a3 update helm charts 2026-06-30 09:08:01 -07:00
Alex Holliday a39792cf0b add readiness and liveness checks to API 2026-06-29 14:14:42 -07:00
Alex Holliday 35c9618d59 server->api 2026-06-29 10:15:29 -07:00
Alex Holliday c6c41c2e3e bare sevice 2026-06-29 10:06:34 -07:00
Alex Holliday f58825d19f clean up env vars 2026-06-29 10:03:48 -07:00
Alex Holliday e4649f3d76 housekeeping 2026-06-29 09:43:37 -07:00
Alexander Holliday c7361666f5 Merge pull request #3742 from bluewave-labs/feat/worker-health
Feat/worker health
2026-06-29 09:20:07 -07:00
Alex Holliday 3f5707721f fix logging on server startup 2026-06-29 09:12:42 -07:00
sanmaxdev cc5f033d0d test: cover maintenance window monitor edits 2026-06-27 16:21:37 +00:00
Alex Holliday d2d0fb788e resolve shutdown race 2026-06-25 12:29:21 -07:00
Alex Holliday 9d969d67ca udpate tests 2026-06-25 11:55:40 -07:00
Alex Holliday 6e1c26b987 remove shadowing var 2026-06-25 11:55:28 -07:00
Alex Holliday ef353dcb36 bugfix, move reconcile to job scheduler 2026-06-25 11:47:22 -07:00
Alex Holliday b21ef60cda add a worker to the dev docker compose 2026-06-25 11:03:04 -07:00
Alex Holliday f43bf9ab3d add observability to worker and health server 2026-06-25 11:02:38 -07:00
Alex Holliday 48ca64ec6f add observability repo method 2026-06-25 11:02:18 -07:00
Alex Holliday e036c6077e add tests 2026-06-25 10:37:07 -07:00
Alex Holliday c9f0254aed start health server, update shutdown, update env config 2026-06-25 10:36:53 -07:00
Alex Holliday b3d9c5b94c add health server and metrics to db queue worker 2026-06-25 10:36:31 -07:00
Alex Holliday fd536541d7 add test for job scheduler drain 2026-06-25 09:16:07 -07:00
Alex Holliday 7a82c76d5c update tests 2026-06-24 12:20:21 -07:00
Alex Holliday 5fab2d0a30 inject buffer service, implement drain 2026-06-24 12:17:02 -07:00
Alex Holliday 860b2d6214 add a getHealth method to interface, stub out implementation 2026-06-24 12:16:37 -07:00
Alex Holliday 8579edd56e add a shutdown method to buffer service 2026-06-24 11:58:32 -07:00
Arnaud GRIMBERT 969d24e369 Merge branch 'develop' into feat/customcss-status-page 2026-06-24 20:27:57 +02:00
Buco7854 52b95ad121 fix: keep the admin status page preview iframe same-origin
The preview framed the page public URL, which for a custom domain is cross
origin and blocked by the admin document CSP frame-src. Point the iframe at the
same-origin public path while the frame chrome still shows the public URL.
2026-06-24 18:21:40 +00:00
Buco7854 ea23610b3a fix: mirror server custom CSS validation on the client
The form only enforced the length cap, so CSS with an external url() or @import
passed client validation and was rejected by the server. Add the same tokenizer
based check with the same error message so it is caught inline before submit.
2026-06-24 18:21:27 +00:00
Buco7854 a4cc322a55 feat: apply status page CSP to custom domains
Detect custom domains by comparing the request host to the app host and apply
the tightened CSP there too, not just on the public path. Run the middleware
before express.static so static-served root documents get the header, and keep
helmet after static so its default-src does not block custom domain API calls.
2026-06-24 18:21:16 +00:00
Alexander Holliday 2b9ff52f6b Merge pull request #3738 from bluewave-labs/feat/clean-worker-split
feat: clean worker split
2026-06-24 11:02:14 -07:00
Alex Holliday 0f230c4d70 update tests 2026-06-24 10:53:13 -07:00
Alex Holliday 74783fd738 clean up logger 2026-06-24 10:53:03 -07:00
Alex Holliday 71d3e789ff split services to api, worker, and shared 2026-06-24 10:30:21 -07:00
Alex Holliday f59a7c3113 extract shared services 2026-06-23 12:26:42 -07:00
Buco7854 ffbf223288 feat: tighten CSP on the public status page document
Layer a stricter Content-Security-Policy on the /status/public document so
custom CSS cannot load external images, fonts, or stylesheets. Browsers
enforce the intersection of all CSP headers, so this only narrows the public
status page and never loosens the global policy, while keeping the app's
Google Fonts. Browser-enforced backstop behind the validator.
2026-06-23 19:25:40 +00:00
Buco7854 96021b7479 refactor: detect external CSS references with the CSS Syntax tokenizer
Replace the regex/escape-normalizer with @csstools/css-tokenizer (already
present transitively). Tokenizing resolves comments, hex escapes, and line
continuations into the real value before scanning, so obfuscated forms (a
scheme split by a line continuation or hex escape) cannot slip past. Scan
url-tokens and string-tokens to cover url(), quoted @import, and string-form
image functions. Add regression tests for the bypasses.
2026-06-23 19:25:18 +00:00
Alex Holliday f0a5974eaf update tests 2026-06-23 11:31:35 -07:00
Alex Holliday c9b8f434cd split worker into a scheduler and worker 2026-06-23 11:22:37 -07:00
Buco7854 3685e0c476 feat: reject custom CSS that references external resources
Reject status page custom CSS containing @import or a url() / image-set()
/ image() / cross-fade() whose target is http(s) or protocol-relative,
while allowing data: and relative URLs. The check normalizes comments and
CSS escapes first so obfuscated forms cannot slip past. Enforced in the
create/update body validation.
2026-06-22 23:49:58 +00:00
Alexander Holliday d6f9fca907 Merge pull request #3737 from bluewave-labs/feat/head-request-fe
feat: head request front end
2026-06-22 11:36:02 -07:00
Alex Holliday ba943e90c2 add form UI 2026-06-22 11:24:35 -07:00
Alex Holliday dead97169f add default GET 2026-06-22 11:24:03 -07:00
Alexander Holliday eb1d307932 Merge pull request #3736 from bluewave-labs/feat/head-request-be
feat: head request back end
2026-06-22 11:19:34 -07:00
Alex Holliday a31dd2f19e update montior form hook to load method 2026-06-22 11:07:56 -07:00
Alex Holliday d84fc7e299 add method to validation 2026-06-22 10:48:39 -07:00
Alex Holliday 8afd67a06d add method to monitor type 2026-06-22 10:43:30 -07:00
Alex Holliday 71a4fddffd add tests 2026-06-22 10:38:15 -07:00
Alex Holliday 3489ee000d add super refine to reject method HEAD and advanced matching/json path 2026-06-22 10:31:13 -07:00
Alex Holliday 93b9c5033a add http metod option, add monitor.method guard to buildResponse 2026-06-22 10:26:10 -07:00
Alex Holliday 32ec909bf7 add default GET for imported monitors 2026-06-22 10:25:27 -07:00
Alex Holliday 8e5757beb1 add a method field to model, add enums and validation for http methods 2026-06-22 10:15:14 -07:00
Alexander Holliday 8e5a337f3e Merge pull request #3735 from bluewave-labs/fix/diagnostic-stats
fix: diagnostic stats
2026-06-22 10:01:25 -07:00
Alex Holliday 9db4e557b3 record last finished timestamp for failures 2026-06-22 09:58:11 -07:00
Alex Holliday 10174da4f0 update locales 2026-06-22 09:54:58 -07:00
Alex Holliday 99ded83bd5 remove dead fields from table, add correct fields 2026-06-22 09:54:49 -07:00
Alex Holliday dffa49ae9d remove dead fields 2026-06-22 09:54:17 -07:00
Alex Holliday 89c86a7628 remove dead fields from WorkerSummary 2026-06-22 09:53:50 -07:00
Alexander Holliday 09446c9fc1 Merge pull request #3733 from bluewave-labs/fix/excess-polling
Fix/excess polling
2026-06-22 09:26:36 -07:00
Alex Holliday 755134ee7f add poll backoff tests 2026-06-22 09:15:39 -07:00
Alex Holliday 3b919044e6 wake worker on job change 2026-06-19 10:25:59 -07:00
Alex Holliday 08e9c02a3a update tests 2026-06-19 10:02:06 -07:00
Alex Holliday 85f674ee08 resovle merge conflict 2026-06-19 09:55:52 -07:00
Alexander Holliday 16036f421b Merge pull request #3734 from bluewave-labs/fix/maintenance-window-exit
fix: change status on exit maintenance window
2026-06-19 09:54:05 -07:00
Alex Holliday 7fe7b79103 update server tests 2026-06-19 09:51:25 -07:00
Alex Holliday a058c0518c change status on exit maintenance window 2026-06-19 09:37:24 -07:00
Alex Holliday 5ed9ab780a update tests, add check producer tests 2026-06-18 11:06:57 -07:00
Alex Holliday e32590425b remove unsed from constructor 2026-06-18 11:06:43 -07:00
Alex Holliday 190a3b47da add findByIdLean for workers 2026-06-18 11:06:19 -07:00
Alex Holliday f740eef0fa thread prefetched worker, remove dead code 2026-06-18 11:05:57 -07:00
Alex Holliday 5efd64d44d thread prefetched monitor trough status service 2026-06-18 11:05:17 -07:00
Alexander Holliday b28c84436e Merge pull request #3718 from harsh-aghara/feat/accept-status-codes-frontend
Add customUpCodes support for HTTP monitors (Frontend)
2026-06-18 10:51:03 -07:00
harsh-aghara 9082cf0826 test(server): add comprehensive edge-case tests for customUpCodes validation 2026-06-18 22:51:21 +05:30
Alex Holliday 9d453bdb0b Merge branch 'develop' into fix/excess-polling 2026-06-18 10:19:57 -07:00
Alexander Holliday 39d92fb067 Merge pull request #3732 from bluewave-labs/fix/maintenance-rearm-jobs
rearm jobs on maintenance window pause/delete
2026-06-18 10:19:40 -07:00
Alex Holliday a54fdd89ab rearm jobs on maintenance window pause/delete 2026-06-18 10:17:33 -07:00
Alexander Holliday 6aa9360b98 Merge pull request #3731 from bluewave-labs/fix/evluation-order
fix: check evaluation order
2026-06-18 10:04:31 -07:00
Alex Holliday bb3f471d19 Merge branch 'fix/evluation-order' into fix/excess-polling 2026-06-18 09:58:47 -07:00
harsh-aghara 6d433fd0ac feat(client): polish customUpCodes ui 2026-06-18 22:27:38 +05:30
harsh-aghara d8dafa311a feat: sync custom http status codes between frontend and backend
- Add standard custom HTTP status codes (Cloudflare, AWS ELB, etc.) to both frontend and backend allowlists.
- Enforce strict validation of custom status codes using Zod sets on both sides, ensuring they match perfectly.
2026-06-18 22:27:38 +05:30
Alex Holliday 398e5d7314 add tests 2026-06-18 09:57:05 -07:00
Alex Holliday 53ee45172d remove evluation from worker 2026-06-18 09:56:56 -07:00
Alex Holliday 1ea6dafb1a evaluate checks on buffer flush 2026-06-18 09:56:38 -07:00
Alex Holliday 9785dc146e inject job repo into buffer service 2026-06-18 09:56:22 -07:00
harsh-aghara 1b1d8ea229 feat(client): implement accepted status codes UI in create monitor form 2026-06-18 21:37:14 +05:30
harsh-aghara 603bd1d814 feat(client): add customUpCodes to Monitor type and validation schema 2026-06-18 21:37:14 +05:30
harsh-aghara 158d976ccb feat(client): add HTTP status codes constants for monitor options 2026-06-18 21:37:14 +05:30
Alex Holliday 380fdf470f backoff excessive polling 2026-06-17 11:35:15 -07:00
Alexander Holliday 1e2a4b4a84 Merge pull request #3730 from bluewave-labs/feat/additional-mongo-stats
feat: additional mongo stats
2026-06-17 11:09:30 -07:00
Alex Holliday 5e0d8f0550 update tests 2026-06-17 11:01:56 -07:00
Alex Holliday bffc4ee813 add stats to fe 2026-06-17 10:55:49 -07:00
Alex Holliday ad9ff7f5df add mongo read/write per second stats 2026-06-17 10:55:39 -07:00
Alexander Holliday 8277c4ed13 Merge pull request #3728 from bluewave-labs/feat/db-queue
feat: db queue
2026-06-17 09:48:40 -07:00
Alex Holliday f65238da6c reformat 2026-06-17 09:45:59 -07:00
Alex Holliday 64b8aab4d6 repair tests 2026-06-17 09:40:51 -07:00
Alex Holliday 7244b35091 add upsertCleanup to schedule immediate cleanup jobs, cleanup orphaned jobs 2026-06-17 09:38:21 -07:00
Alex Holliday 699883e0b1 add jitter and tests 2026-06-16 11:34:58 -07:00
Alex Holliday f7211711ce add db queue worker tests 2026-06-16 10:59:56 -07:00
Alex Holliday cb0dd878d6 update .env.example 2026-06-16 10:59:46 -07:00
Alex Holliday 8c5be9ef00 removed pulsecron and ioredis 2026-06-16 10:50:50 -07:00
Alex Holliday d1b6d08c73 add tests 2026-06-16 10:50:39 -07:00
Alex Holliday 2d68e96782 drop jobs in migration 2026-06-16 10:50:29 -07:00
Alex Holliday 306f2a60a2 update tests 2026-06-15 12:22:55 -07:00
Alex Holliday abf6dc2908 single worker ID 2026-06-15 12:21:36 -07:00
Alex Holliday 7ce6068757 fix migration 2026-06-15 12:12:14 -07:00
Alex Holliday a7d9bb822e add tests, fix failing tests 2026-06-15 12:05:31 -07:00
Alex Holliday 142ecaaf8b handle renewing locks for long running jobs 2026-06-15 12:05:15 -07:00
Alex Holliday 90bbc764fc readd queue-worker domain, add heartbewat to db-queue 2026-06-15 11:26:49 -07:00
Alex Holliday ae4c433246 add a feature flag to toggle primary worker processing jobs or not 2026-06-15 11:02:46 -07:00
Alex Holliday d2461ef118 remove old queues 2026-06-15 10:39:48 -07:00
Alex Holliday 47162f2064 add a batch claim method 2026-06-12 16:04:31 -07:00
Alex Holliday 8b9c41464a dbQueueWorker 2026-06-12 15:43:36 -07:00
Alex Holliday 51b6ec1751 add check producer and evaluator 2026-06-12 12:36:50 -07:00
Buco7854 6165ae60e6 Merge branch 'develop' of https://github.com/bluewave-labs/checkmate into feat/customcss-status-page
# Conflicts:
#	server/src/api/routes/statusPageRoute.ts
2026-06-12 16:01:03 +00:00
Buco7854 69e142f711 fix: restrict status page mutations to admins and superadmins
Creating, updating and deleting status pages was only guarded by
verifyJWT, so any authenticated team member could set fields including
the new customCSS via a direct API call. The admin gating existed only
in the client UI (hidden buttons), and the create/configure routes had
no guard, so the form was reachable by URL. Enforce the role at the API
with isAllowed, matching the monitor routes.
2026-06-12 15:43:58 +00:00
Alex Holliday eff4f8ad69 fix method name 2026-06-11 15:20:13 -07:00
Alex Holliday bca902471d update tests 2026-06-11 15:19:53 -07:00
Alex Holliday 1b3f2f0365 add toStatusResponse to service, add findUnevaluated to repo 2026-06-11 15:19:45 -07:00
Alex Holliday be12991997 add tests 2026-06-11 14:19:21 -07:00
Alex Holliday 54596fe776 add mongodb-memory-server for testing 2026-06-11 14:19:14 -07:00
Alex Holliday 8d637dbcac update validation 2026-06-11 14:18:56 -07:00
Alex Holliday 771fd5b50f add migration for lastEvaluatedAt backfill 2026-06-11 14:18:44 -07:00
Alex Holliday 8dedf2a742 add last evaluated at field 2026-06-11 14:18:32 -07:00
Alex Holliday c882daf000 add job domain 2026-06-11 14:18:21 -07:00
Alexander Holliday 1aa7ae8f11 Merge pull request #3715 from Cincinnatus101010/feat/status-page-custom-domain
feat: custom domain support for published status pages
2026-06-11 12:31:20 -07:00
Ian Troisi 517139cbf4 fix: cache negative custom domain CORS lookups
Use a shorter TTL for rejected origins so unknown domains do not hit
the database on every cross-origin request.
2026-06-11 14:48:31 -04:00
Ian Troisi ef049b6521 fix: resolve CORS TypeScript error and format server files
Type defaultCorsOptions as CorsOptions so allowedHeaders is compatible
with the cors middleware. Format files from develop merge for CI.
2026-06-11 13:55:10 -04:00
Ian Troisi 33f8641f3a Merge branch 'develop' into feat/status-page-custom-domain 2026-06-11 13:54:27 -04:00
Alexander Holliday 2f22a52116 Merge commit from fork
Advisory fix 1
2026-06-11 10:44:42 -07:00
Ian Troisi 8af13b04ec fix: derive custom domain URL protocol from client host
Use the configured client host protocol instead of hardcoding https for
custom domain status page links.
2026-06-11 13:43:19 -04:00
Alex Holliday ac77216f07 add middleware tests 2026-06-11 10:42:15 -07:00
Alex Holliday a43363ebfc apply middleware 2026-06-11 10:42:05 -07:00
Alex Holliday 0ca738b080 add an upload type with size constraints and acceptable file types 2026-06-11 10:41:53 -07:00
Alex Holliday 091c36cbf3 create upload middleware 2026-06-11 10:40:59 -07:00
Ian Troisi 120708ed47 perf: cache custom domain CORS origin lookups
Avoid hitting the database on every cross-origin request by caching
published custom domain checks for five minutes.
2026-06-11 13:15:02 -04:00
Ian Troisi 6a1ab622b3 fix: scope custom domain CORS to public status page routes
Only allow registered custom domain origins on public GET/OPTIONS status
page API routes instead of the entire application API surface.
2026-06-11 13:15:02 -04:00
Ian Troisi 0aef8b652f fix: use partial index for status page customDomain
Replace the sparse unique index with a partial index so multiple status
pages without a custom domain no longer collide on null values.
2026-06-11 13:14:52 -04:00
Alexander Holliday 20863fbce7 Merge pull request #3725 from bluewave-labs/feat/decouple-worker
feat: decouple worker
2026-06-10 13:24:11 -07:00
Alex Holliday fa7eaa4788 update tests 2026-06-10 13:13:01 -07:00
Alex Holliday ee9c08b1bb extract reactors and pipeline 2026-06-10 13:12:51 -07:00
Alexander Holliday 7ee30e12f5 Merge pull request #3724 from bluewave-labs/chore/worker-reorg
chore: worker reorg
2026-06-10 09:34:31 -07:00
Alex Holliday c5f57eda62 update tests 2026-06-10 09:30:24 -07:00
Alex Holliday 2bf3c08f3c reorg jobqueue to worker 2026-06-10 09:30:14 -07:00
Ian Troisi 187c8eaba1 chore: format openapi.json with prettier 2026-06-10 11:03:34 -04:00
Buco7854 25d8839441 feat: render admin status page preview in an iframe
The preview previously rendered the themed status page inline in the
admin document, so document-global concerns leaked in both directions:
custom CSS spilled into the admin UI and the theme body painting never
showed. Pointing the BrowserFrame at the real public URL in an iframe
isolates the CSS completely and makes the preview pixel-identical to
what visitors see. Auth for unpublished pages keeps working because the
same-origin iframe shares localStorage and the API client reads the
token from the rehydrated store.
2026-06-10 17:02:18 +02:00
Buco7854 0f4240d3db feat: cap custom CSS length and add validation tests
Bound customCSS to 100000 characters on both the client schema and the
server body validation (the server is the enforcement boundary; multer's
1MB field cap was the only limit), regenerate the OpenAPI spec, and add
a validation test suite covering acceptance, clearing, the length cap,
and type rejection.
2026-06-10 17:02:17 +02:00
Ian Troisi a37bc63f31 Merge branch 'develop' into feat/status-page-custom-domain
Resolve conflicts from server reorganization while preserving custom
domain support for published status pages.
2026-06-10 10:55:59 -04:00
Buco7854 73f95ac8e1 feat: complete custom CSS support for status pages
The customCSS field existed on the model, types, and form schema but was
never editable, sent to the API, or rendered. Wire it end to end:

- Accept customCSS in the create/update status page body validation and
  regenerate the OpenAPI spec
- Add a Custom CSS editor (multiline field) to the status page form and
  include the value in the submitted form data
- Support multiline mode in the shared TextInput component
- Inject the saved CSS via a style tag when rendering the status page
2026-06-10 14:16:27 +00:00
Alexander Holliday c8c4bac2c7 Merge pull request #3719 from bluewave-labs/chore/organization
chore: organization
2026-06-09 12:40:06 -07:00
Alex Holliday 9fecfa562f update claude.md 2026-06-09 12:36:38 -07:00
Alex Holliday 7991f3f6a1 ignore unknown on format on client 2026-06-09 12:36:16 -07:00
Alex Holliday 692d4d89d6 update configs 2026-06-09 12:35:57 -07:00
Alex Holliday f1b104b78d ignore unknown on format 2026-06-09 12:35:19 -07:00
Alex Holliday 3c2b571313 update openapi docs 2026-06-09 12:34:57 -07:00
Alex Holliday abec4e6c70 update tests 2026-06-09 12:34:30 -07:00
Alex Holliday d022f7d849 reorg to domain and api 2026-06-09 12:34:15 -07:00
Alexander Holliday 0fb210e9e1 Merge pull request #3713 from Rabson/fix/diagnostics-totalsize-undefined
fix: (diagnostics) derive Mongo totalSize when dbStats omits it (Atlas shared tier)
2026-06-09 09:09:47 -07:00
Alexander Holliday ce4cb63035 Merge pull request #3690 from harsh-aghara/feat/accept-status-codes-backend
Add customUpCodes support for HTTP monitors (Backend)
2026-06-08 12:04:42 -07:00
harsh-aghara f637b01631 refactor: introduced helper method to remove redundant code 2026-06-08 17:30:26 +05:30
Ian Troisi e390bd764f Format openapi.json for CI format-check. 2026-06-07 22:42:58 -04:00
Ian Troisi 30e8cfc9ec Add custom domain support for published status pages.
Fixes #3647
2026-06-07 22:37:24 -04:00
Alexander Holliday 80d6868a1d Merge pull request #3714 from bluewave-labs/fix/status-page-styling
fix: status page styling
2026-06-07 15:23:06 -07:00
Alex Holliday 942d75afcd sm -> md 2026-06-07 15:21:53 -07:00
Alex Holliday c34fcc5cb3 fix responsiveness for status page themes 2026-06-07 15:18:22 -07:00
Alex Holliday 409f9f6dc6 fix status page styling 2026-06-07 10:54:58 -07:00
Alexander Holliday 525206c1c1 Merge pull request #3711 from bluewave-labs/feat/uptime-percentage
feat: uptime percentage resolves #3703
2026-06-07 10:18:56 -07:00
Alex Holliday 9a85389b8c update tests 2026-06-07 10:16:31 -07:00
Alex Holliday eeac784922 dispaly uptime percentage on status pages 2026-06-07 10:02:44 -07:00
Alex Holliday 19b873ca87 convert findByIds to aggregate pipeline to include uptime percentage 2026-06-07 10:02:25 -07:00
Alex Holliday 0f99c7004d remove unused import 2026-06-07 09:44:44 -07:00
Rabson 6a566c3693 fix(diagnostics): derive Mongo totalSize when dbStats omits it
MongoDB Atlas shared/free tiers return a legacy dbStats shape that omits
`totalSize` (while still returning storageSize/indexSize). diagnosticService
passed that undefined straight through, so the Diagnostics UI crashed with
"Expected a finite number, got undefined".

Derive totalSize (storageSize + indexSize) at the DB and per-collection level
so it always satisfies the non-optional `totalSize: number` type. Confirmed it
is not a version removal (stock mongo:8 returns totalSize) nor a permissions
issue (missing even with atlasAdmin). Adds unit tests for the derivation.
2026-06-07 08:58:09 +08:00
Alex dfcf0309fe add uptime percentage label 2026-06-06 16:08:35 -07:00
Alex 395b7fbe2a add uptimetime stats method and aggregation 2026-06-06 16:08:00 -07:00
harsh-aghara d2c13fac5a Merge remote-tracking branch 'upstream/develop' into feat/accept-status-codes-backend
# Conflicts:
#	server/src/service/infrastructure/network/HttpProvider.ts
2026-06-05 00:58:35 +05:30
harsh-aghara dd16ac060a fix: run advanced matcher on HTTP errors with custom up codes
When customUpCodes mark an HTTPError as up, parse the response body, apply advancedMatcher.validate, and set status to statusUp && matchResult.ok. Return parsed payload and extracted values to match the success path.
2026-06-05 00:35:30 +05:30
harsh-aghara 5628c202f7 refactor: extract isStatusUp utility to decouple from got's response.ok and modified utils test accordingly 2026-06-04 22:43:43 +05:30
harsh-aghara 372fc9869b refactor(monitor): address PR feedback for customUpCodes
Add HttpStatusCode types and simplify provider logic
2026-06-03 23:39:26 +05:30
harsh-aghara 87a6640c1d fix(backend): validate customUpCodes against standard HTTP status codes
Addresses reviewer feedback by enforcing strict validation on the  array. Previously, the Zod schema accepted any arbitrary number, which could allow invalid or self-defined HTTP codes (e.g., 5000) to be saved to the database.

Refs #3657
2026-06-01 13:21:30 +05:30
harsh-aghara 4e31499f78 feat(backend): add customUpCodes support for HTTP monitors
Adds a customUpCodes field to the monitor schema to allow users to specify which HTTP status codes should be treated as UP instead of DOWN. Includes core logic in HttpProvider and unit tests.

Refs #3657
2026-06-01 00:10:55 +05:30
573 changed files with 21491 additions and 14803 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
**/node_modules
**/dist
**/.env
**/*.env
.git
docker/dev/mongo/data
+103
View File
@@ -0,0 +1,103 @@
name: Build image
on:
push:
branches: ["develop", "master"]
tags: ["v*"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# checkmate-backend-mono is a transitional alias so existing deployments
# keep receiving updates; drop it after one release cycle.
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/checkmate
ghcr.io/${{ github.repository_owner }}/checkmate-backend-mono
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# The hosted staging/prod instances share one host and one compose file; each
# deploy pulls and recreates only its own service.
deploy-staging:
needs: build-and-push
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Deploy staging.checkmate.so
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docs/hosting/checkmate-so
docker compose pull checkmate-staging
docker compose up -d checkmate-staging
docker system prune -af
deploy-prod:
needs: build-and-push
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Deploy demo.checkmate.so
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docs/hosting/checkmate-so
docker compose pull checkmate-demo
docker compose up -d checkmate-demo
docker system prune -af
@@ -1,148 +0,0 @@
name: Deploy images on release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Client Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }}
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server-mono-multiarch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:${{ steps.extract_tag.outputs.version }}
platforms: linux/amd64,linux/arm64
labels: |
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
build-args: |
VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server-mono:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }} \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }}
-143
View File
@@ -1,143 +0,0 @@
name: Deploy images
on:
push:
branches: ["master"]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-client:latest \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-client:latest
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend:latest \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-backend:latest
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-mongo:latest \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-mongo:latest
docker-build-and-push-server-mono-multiarch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:latest
platforms: linux/amd64,linux/arm64
labels: |
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
build-args: |
VITE_APP_VERSION=${{ env.VERSION }}
docker-build-and-push-server-mono:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend-mono:latest \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:latest
-91
View File
@@ -1,91 +0,0 @@
name: Demo deploy
on:
push:
branches: [ "demo" ]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-demo \
-f ./docker/prod/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-demo
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-demo \
-f ./docker/prod/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-demo
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-demo \
-f ./docker/prod/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-demo
deploy-to-demo:
needs: docker-build-and-push-server
runs-on: ubuntu-latest
steps:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
-91
View File
@@ -1,91 +0,0 @@
name: Staging deploy
on:
push:
branches: [ "develop" ]
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-staging \
-f ./docker/staging/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-staging
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-staging \
-f ./docker/staging/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-staging
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-staging \
-f ./docker/staging/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-staging
deploy-to-staging:
needs: docker-build-and-push-server
runs-on: ubuntu-latest
steps:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
+19 -15
View File
@@ -35,9 +35,9 @@ npm run format # Prettier formatting
### Docker Development
```bash
cd docker/dev
./build_images.sh
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
docker build -f docker/Dockerfile -t checkmate . # build the mono image (server + built client)
docker compose -f docker/dev/docker-compose.yaml up # local full stack (builds image, runs mongo + worker)
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0 # just a dev database
```
## Environment Setup
@@ -68,19 +68,23 @@ VITE_APP_LOG_LEVEL="debug"
### Backend Layers
```
server/src/
├── controllers/ # Route handlers (authController, monitorController, etc.)
├── service/ # Business logic
│ ├── business/ # Core monitoring logic
│ ├── infrastructure/ # Server/system utilities
│ └── system/ # App-level settings
├── api/ # HTTP layer
│ ├── controllers/ # Route handlers (authController, monitorController, etc.)
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler
│ ├── routes/ # API route definitions
│ └── validation/ # Zod request-payload schemas
├── domain/ # Business logic + data access, one folder per entity
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
│ ├── checks/
│ ├── incidents/
│ └── … # users, teams, status-pages, notifications, etc.
├── service/ # Cross-cutting services (e.g. infrastructure/)
├── config/ # App config wiring (controllers, routes, services, envValidation)
├── db/
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
│ ├── migration/ # Database migrations (run on startup)
│ └── modules/ # Database-specific modules
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
├── routes/v1/ # API route definitions
├── validation/ # Joi input validation schemas
└── repositories/ # Data access layer
│ └── migration/ # Database migrations (run on startup)
├── templates/ # Email/notification templates
├── types/ # Shared const tuples + derived types
└── utils/ # Shared utilities
```
### Frontend Structure
+30 -25
View File
@@ -88,34 +88,10 @@ cd Checkmate
#### Step 2: Set Up Docker Containers (MongoDB)
Navigate to the Docker dev directory:
```bash
cd docker/dev
```
Build the Docker images:
Linux:
```bash
./build_images.sh
```
Windows:
```bash
./build_images.bat
```
Run MongoDB container:
```bash
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
```
Navigate back to the root directory:
```bash
cd ../..
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0
```
#### Step 3: Set Up the Backend (Server)
@@ -256,6 +232,35 @@ docker rm uptime_database_mongo
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
### Improve the documentation?
Documentation contributions are very welcome — fixing a typo, clarifying a step, or adding a missing example is a valuable PR.
- Small edits (typos, wording, formatting): open a PR directly against `develop`.
- Larger changes (restructuring a section, adding a new guide): open an issue first so we can align on scope, then a PR.
- Screenshots are helpful when documenting UI behaviour.
- README translations live in `docs/translations/`. If you're adding a new language, please also add a flag + link to the language bar in the root `README.md` and every existing translated README so they stay in sync.
### Help with translations?
The application uses `i18next` with one JSON file per language under `client/src/locales/` (`en.json` is the source of truth). English source strings are the definitive copy; other languages are generated as one-time machine translations that anyone is welcome to refine.
**To improve an existing language:**
1. Edit the relevant `client/src/locales/<code>.json` file directly.
2. Keep the JSON key structure identical to `en.json` — only translate the string values.
3. Open a PR against `develop`. No language-selector code changes are needed.
**To add a new language:**
1. Add `client/src/locales/<code>.json` with the same key structure as `en.json`, translated. If you can't translate every key, leave unfamiliar strings in English rather than removing keys — i18next falls back to the key path when a value is missing.
2. Add the display name to `languageNames` in `client/src/Components/inputs/LanguageSelector.tsx` (e.g. `it: "Italiano"`). The selector picks up any language present in `resources` automatically.
3. Open a PR against `develop`.
If translating a whole file is more than you want to take on, open an issue asking us to add the language and we'll generate an initial machine-translated file that you (or others) can refine over time.
**Note:** an older workflow that synced translations with PoEditor is still in the repo but no longer authoritative. Please treat the `.json` files in `client/src/locales/` as the source of truth.
### Submit a pull request?
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
+32 -1
View File
@@ -9,6 +9,7 @@
🇪🇸 <a href="docs/translations/README.es.md">Español</a> |
🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> |
🇫🇷 <a href="docs/translations/README.fr.md">Français</a> |
🇮🇹 <a href="docs/translations/README.it.md">Italiano</a> |
🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> |
🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> |
🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> |
@@ -79,7 +80,37 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a>
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
```bash
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
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.
### Configuration
The image is configured entirely through environment variables on the server container:
| Variable | Required | Description |
|---|---|---|
| `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 |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
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:
| Variable | Description |
|---|---|
| `CLIENT_CONFIG_API_BASE_URL` | Full base URL the client calls the API at, e.g. `https://api.example.com/api/v1`; defaults to same-origin `/api/v1` |
| `CLIENT_CONFIG_CLIENT_HOST` | Origin used when the client builds absolute links (invites, status pages); defaults to the browser's current origin |
| `CLIENT_CONFIG_LOG_LEVEL` | Browser console log level: `error`, `warn`, `info`, or `debug` (default `error`) |
> **Upgrading from an older image?** The `UPTIME_APP_*` variables (`UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST`, `UPTIME_APP_LOG_LEVEL`) are no longer read. In most setups no replacement is needed — the same-origin defaults cover them; if you pointed the client at a different origin, use the `CLIENT_CONFIG_*` equivalents above. The `checkmate-client`, `checkmate-backend`, `checkmate-mongo`, and `checkmate-backend-mono-multiarch` images are no longer updated — switch to `ghcr.io/bluewave-labs/checkmate`, keeping your existing MongoDB service and data volume.
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.
+6 -2
View File
@@ -2,5 +2,9 @@ apiVersion: v2
name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.1.0
appVersion: "2.3"
version: 0.2.0
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
# are never published, so a bumped chart will fail to pull.
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+142 -12
View File
@@ -19,12 +19,11 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml
Edit `values.yaml` to update:
- `client.ingress.host` and `server.ingress.host` with your domain names
- `server.protocol` (usually http or https)
- `client.ingress.host` and `api.ingress.host` with your domain names
- `api.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Old: `persistence.redisSize` → New: `persistence.redis.size`
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
- Add: `persistence.mongo.storageClass` (leave empty for default)
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
@@ -32,7 +31,7 @@ Edit `values.yaml` to update:
```bash
helm install checkmate ./charts/helm/checkmate
```
This will deploy the client, server, MongoDB, and Redis components.
This will deploy the client, api, and MongoDB components.
### 4. Verify the deployment
Check pods and services:
@@ -43,6 +42,137 @@ kubectl get svc
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
## Upgrading from a previous release
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
Read this before running `helm upgrade` on an existing install.
### `server.*` → `api.*` (values rename)
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
don't mix them.
### Resource renames cause a replace, not a rename
The Kubernetes objects are renamed alongside the values:
| Old object | New object |
| --- | --- |
| Deployment/Service `checkmate-server` | `checkmate-api` |
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
### Redis removed
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
keys in your values file are harmless (ignored).
### New secret defaults
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
block and merge into your install unless you override them. Set them explicitly if you relied on
different values (e.g. a non-production `NODE_ENV`).
### Image tags follow the chart's `appVersion`
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
chart. You can still pin per tier:
```yaml
api:
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
```
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
`api.image`) unless you set `worker.tag` / `worker.image`.
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
let it default to `appVersion`) for reproducible, rollback-able deploys.
## Scaling: the worker tier & autoscaling
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
horizontally-scalable worker tier.
### Topology
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
| --- | --- | --- |
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
and worker tier can never both process (or both ignore) the queue.
### Enabling the worker tier
```yaml
worker:
enabled: true
replicas: 2 # used only when autoscaling.enabled = false
```
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
> checks.
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
> this; full failover to the API would need leader election (not supported).
### Autoscaling with KEDA
Backlog-driven autoscaling is **off by default** and requires the
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
```yaml
worker:
enabled: true
autoscaling:
enabled: true
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
maxReplicaCount: 10
backlogPerReplica: 50 # target due-job backlog per worker
mongo:
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
```
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
backlog → never scales up).
#### Fallback: Prometheus-adapter HPA (not shipped)
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
`now` in code on this path, so the query coercion is unnecessary.
## Enabling TLS/HTTPS with cert-manager
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
@@ -68,7 +198,7 @@ client:
enabled: true
secretName: checkmate-client-tls
server:
api:
protocol: https
ingress:
enabled: true
@@ -78,7 +208,7 @@ server:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: true
secretName: checkmate-server-tls
secretName: checkmate-api-tls
```
### Alternative: Using --set flags
@@ -88,13 +218,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
```bash
helm install checkmate ./charts/helm/checkmate \
--set client.protocol=https \
--set server.protocol=https \
--set api.protocol=https \
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set client.ingress.tls.enabled=true \
--set client.ingress.tls.secretName=checkmate-client-tls \
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set server.ingress.tls.enabled=true \
--set server.ingress.tls.secretName=checkmate-server-tls
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set api.ingress.tls.enabled=true \
--set api.ingress.tls.secretName=checkmate-api-tls
```
### Verification
@@ -107,7 +237,7 @@ kubectl get certificates
# Check certificate details
kubectl describe certificate checkmate-client-tls
kubectl describe certificate checkmate-server-tls
kubectl describe certificate checkmate-api-tls
# Verify the secrets were created
kubectl get secrets | grep checkmate-tls
@@ -0,0 +1,35 @@
{{- /*
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
still pass a legacy `server:` block keep working — this returns the effective API values with any
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
{{- $api := include "checkmate.api" . | fromYaml -}}
then reference `$api.*` instead of `.Values.api.*`.
*/}}
{{- define "checkmate.api" -}}
{{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- end -}}
{{- /*
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
*/}}
{{- define "checkmate.image" -}}
{{- $lastSegment := .image | splitList "/" | last -}}
{{- if contains ":" $lastSegment -}}
{{- .image -}}
{{- else -}}
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,65 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-api
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate-api
template:
metadata:
labels:
app: checkmate-api
spec:
{{- with $api.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $api.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: api
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
imagePullPolicy: {{ $api.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $api.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
{{- with $api.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -1,11 +1,12 @@
{{- if .Values.server.ingress.enabled }}
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if $api.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-server-ingress
{{- if .Values.server.ingress.annotations }}
name: checkmate-api-ingress
{{- if $api.ingress.annotations }}
annotations:
{{- range $key, $value := .Values.server.ingress.annotations }}
{{- range $key, $value := $api.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
@@ -17,22 +18,22 @@ metadata:
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec:
ingressClassName: {{ .Values.server.ingress.className }}
{{- if .Values.server.ingress.tls.enabled }}
ingressClassName: {{ $api.ingress.className }}
{{- if $api.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.server.ingress.host }}
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
- {{ $api.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.server.ingress.host }}
- host: {{ $api.ingress.host }}
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: checkmate-server
name: checkmate-api
port:
number: {{ .Values.server.port }}
number: {{ $api.port }}
{{- end }}
@@ -0,0 +1,10 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate-api
spec:
selector:
app: checkmate-api
ports:
- port: {{ $api.port }}
@@ -1,3 +1,4 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -22,12 +23,13 @@ spec:
{{- end }}
containers:
- name: client
image: {{ .Values.client.image }}
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
ports:
- containerPort: {{ .Values.client.port }}
env:
- name: UPTIME_APP_API_BASE_URL
value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts:
@@ -41,4 +43,4 @@ spec:
volumes:
- name: config-volume
configMap:
name: checkmate-server-nginx-cm
name: checkmate-client-nginx-cm
@@ -1,7 +1,8 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: ConfigMap
kind: ConfigMap
metadata:
name: checkmate-server-nginx-cm
name: checkmate-client-nginx-cm
data:
default.conf: |
server {
@@ -22,7 +23,7 @@ data:
}
# location /api/ {
# proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
@@ -31,7 +32,7 @@ data:
# }
location /api-docs/ {
proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -22,7 +22,8 @@ spec:
{{- end }}
containers:
- name: mongodb
image: {{ .Values.mongodb.image }}
image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }}
imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }}
ports:
- containerPort: {{ .Values.mongodb.port }}
command: ["mongod", "--quiet", "--bind_ip_all"]
@@ -37,7 +38,9 @@ spec:
- metadata:
name: checkmate-mongo-persistent-storage
spec:
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
{{- with .Values.persistence.mongo.storageClass }}
storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}}
{{- end }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
+20 -14
View File
@@ -1,14 +1,15 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- if eq .Values.server.ingress.host "change_me" }}
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
{{- if eq $api.ingress.host "change_me" }}
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $serverProtocol := .Values.server.protocol }}
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
{{- fail "server.protocol must be either 'http' or 'https'" }}
{{- $apiProtocol := $api.protocol }}
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
{{- fail "api.protocol must be either 'http' or 'https'" }}
{{- end }}
{{- $clientProtocol := .Values.client.protocol }}
@@ -21,28 +22,33 @@
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
{{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
{{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
{{- $aAnn := $api.ingress.annotations | default dict }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
@@ -1,11 +0,0 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: checkmate-redis
spec:
selector:
app: checkmate-redis
ports:
- port: {{ .Values.redis.port }}
{{- end }}
@@ -1,33 +0,0 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: checkmate-redis
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-redis
template:
metadata:
labels:
app: checkmate-redis
spec:
containers:
- name: redis
image: {{ .Values.redis.image }}
ports:
- containerPort: {{ .Values.redis.port }}
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: checkmate-redis-persistent-storage
spec:
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
{{- end }}
@@ -4,6 +4,14 @@
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
{{- end }}
{{- /*
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
the one connection string resolves from every namespace. External/already-qualified hosts are not
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
*/}}
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
apiVersion: v1
kind: Secret
metadata:
@@ -11,5 +19,9 @@ metadata:
type: Opaque
stringData:
{{- range $key, $value := $secrets }}
{{- if eq $key "DB_CONNECTION_STRING" }}
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
{{- else }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
@@ -1,37 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-server
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-server
template:
metadata:
labels:
app: checkmate-server
spec:
{{- with .Values.server.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: server
image: {{ .Values.server.image }}
ports:
- containerPort: {{ .Values.server.port }}
envFrom:
- secretRef:
name: checkmate-secrets
{{- with .Values.server.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
@@ -1,9 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-server
spec:
selector:
app: checkmate-server
ports:
- port: {{ .Values.server.port }}
@@ -0,0 +1,66 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-worker
spec:
{{- if not .Values.worker.autoscaling.enabled }}
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
{{- end }}
selector:
matchLabels:
app: checkmate-worker
template:
metadata:
labels:
app: checkmate-worker
spec:
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
{{- with .Values.worker.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.worker.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
ports:
- name: health
containerPort: {{ .Values.worker.healthPort }}
envFrom:
- secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env:
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "worker"
- name: HEALTH_PORT
value: {{ .Values.worker.healthPort | quote }}
livenessProbe:
httpGet:
path: /livez
port: health
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
{{- with .Values.worker.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
{{- end }}
@@ -0,0 +1,30 @@
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: checkmate-worker
spec:
scaleTargetRef:
name: checkmate-worker
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
triggers:
- type: mongodb
metadata:
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
query: >-
{ "type": "check", "isActive": true,
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
"$or": [ { "lockedUntil": null },
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
{{- end }}
+64 -26
View File
@@ -1,5 +1,7 @@
client:
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
image: ghcr.io/bluewave-labs/checkmate-client
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 80
protocol: http
ingress:
@@ -17,10 +19,24 @@ client:
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
server:
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
api:
image: ghcr.io/bluewave-labs/checkmate-backend
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 52345
protocol: http
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
probes:
liveness:
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness:
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
ingress:
enabled: true
host: change_me
@@ -31,40 +47,62 @@ server:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
# Note: when enabling TLS, also set server.protocol: https and add
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
# Note: when enabling TLS, also set api.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
redis:
enabled: false
image: redis:7.2
port: 6379
mongodb:
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
image: ghcr.io/bluewave-labs/checkmate-mongo
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 27017
worker:
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# API is all-in-one and processes jobs itself.
enabled: false
image: "" # defaults to api.image when empty, same as API image
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
replicas: 1 # used only when autoscaling.enabled = false
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
resources: {}
affinity: {}
tolerations: {}
probes:
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness: # /readyz — flips to 503 on drain
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
autoscaling:
enabled: false # requires the KEDA operator installed in-cluster
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
maxReplicaCount: 10
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
cooldownPeriod: 300 # seconds before scaling an idle target down
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
mongo:
dbName: uptime_db # must match DB_CONNECTION_STRING's database
collection: jobs
secrets:
JWT_SECRET: change_me
# REFRESH_TOKEN_SECRET: change_me
# SYSTEM_EMAIL_ADDRESS: test@example.com
# SYSTEM_EMAIL_PASSWORD: change_me
# SYSTEM_EMAIL_HOST: smtp.example.com
# SYSTEM_EMAIL_PORT: "587"
# PAGESPEED_API_KEY: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
CLIENT_HOST: change_me
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# DB_TYPE: MongoDB
# TOKEN_TTL: 99d
# REFRESH_TOKEN_TTL: 99d
NODE_ENV: production
LOG_LEVEL: info
TOKEN_TTL: 99d
persistence:
mongo:
size: 5Gi
storageClass: ""
redis:
size: 1Gi
storageClass: ""
+6 -4
View File
@@ -1,4 +1,6 @@
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
# Production builds use same-origin defaults: API at /api/v1, client host from
# window.location.origin. The empty values below override any local .env so a
# production build never bakes in a developer's localhost URLs.
VITE_APP_API_BASE_URL=
VITE_APP_CLIENT_HOST=
VITE_APP_LOG_LEVEL=
-1
View File
@@ -28,5 +28,4 @@ dist-ssr
# Scripts
*.sh
!env.sh
.env.development
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
for i in $(env | grep UPTIME_APP_)
do
key=$(echo $i | cut -d '=' -f 1)
value=$(echo $i | cut -d '=' -f 2-)
echo $key=$value
find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
done
+1
View File
@@ -12,6 +12,7 @@
/>
<title>Checkmate</title>
<script src="/config.js"></script>
</head>
<body>
+321 -211
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.8.1",
"version": "3.10.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,9 +14,10 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write"
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
@@ -32,7 +33,7 @@
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
"i18next": "25.4.2",
"joi": "17.13.3",
"joi": "17.13.4",
"lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
+4
View File
@@ -0,0 +1,4 @@
// Fallback so /config.js always resolves (e.g. under the Vite dev server). In
// the Docker image the server shadows this path and renders the real config
// from its CLIENT_CONFIG_* environment variables.
window.__CHECKMATE_CONFIG__ = {};
@@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { useTranslation } from "react-i18next";
import prettyMilliseconds from "pretty-ms";
type HeatmapCheck =
| CheckSnapshot
@@ -58,7 +59,9 @@ export const HeatmapResponseTimeTooltip = ({
</Typography>
<Typography>
{t("common.labels.responseTime")}:{" "}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
{check?.responseTime
? prettyMilliseconds(check.responseTime, { compact: true })
: "N/A"}
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
@@ -8,6 +8,7 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -29,12 +30,12 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats
return { max: "-", avg: "-" };
}
const validChecks = checks.filter((check) => check.originalResponseTime != null);
const validChecks = checks.filter((check) => check.responseTime != null);
if (validChecks.length === 0) {
return { max: "-", avg: "-" };
}
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const responseTimes = validChecks.map((check) => check.responseTime);
const max = Math.round(Math.max(...responseTimes));
const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
@@ -54,7 +55,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
@@ -111,7 +112,7 @@ export const HistogramResponseTime = ({
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const heightPct = `${barHeights[index] ?? 0}%`;
const barColor =
check.status === true
? theme.palette.success.light
@@ -13,6 +13,7 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string;
maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
export const BaseChart = ({
@@ -21,16 +22,22 @@ export const BaseChart = ({
width = "100%",
maxWidth = "100%",
padding,
onClick,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
onClick={onClick}
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}}
>
<Stack
@@ -40,6 +47,7 @@ export const BaseChart = ({
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
+14 -4
View File
@@ -1,18 +1,28 @@
import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps {
color?: string;
size?: string;
size?: DotSize;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
return (
<span
style={{
content: '""',
width: size,
height: size,
width: DOT_SIZE_PX[size],
height: DOT_SIZE_PX[size],
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
@@ -13,7 +13,9 @@ export const languageNames: Record<string, string> = {
ca: "Català",
es: "Español",
fr: "Français",
it: "Italiano",
"pt-BR": "Português (BR)",
pl: "Polski",
cs: "Čeština",
fi: "Suomi",
tr: "Türkçe",
+1 -1
View File
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
@@ -0,0 +1,34 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -1,53 +1,40 @@
import Stack from "@mui/material/Stack";
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography";
import prettyMilliseconds from "pretty-ms";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import type { GroupedCheck } from "@/Types/Check";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
import { useMemo, useState } from "react";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
PHASE_COLOR_KEYS,
PHASE_KEYS,
PHASE_LABEL_KEYS,
} from "@/Components/monitors/charts/ChartUtils";
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type DetailsCheck = GroupedCheck &
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
@@ -63,17 +50,72 @@ const ResponseTimeToolTip = ({
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
<Typography>
{t("common.charts.histogram.responseTime", {
value: prettyMilliseconds(responseTime, {
formatSubMilliseconds: true,
compact: true,
}),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: prettyMilliseconds(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}:{" "}
{prettyMilliseconds(bucket[key], {
formatSubMilliseconds: true,
compact: true,
})}
</Typography>
</Stack>
))}
</BaseBox>
);
};
@@ -82,11 +124,28 @@ export const HistogramDetails = ({
checks,
range,
}: {
checks: GroupedCheck[];
checks: DetailsCheck[];
range: string;
}) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return (
<BaseChart
icon={
@@ -95,7 +154,15 @@ export const HistogramDetails = ({
strokeWidth={1.5}
/>
}
title="Response times"
title={
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
>
<ResponsiveContainer
width="100%"
@@ -128,7 +195,34 @@ export const HistogramDetails = ({
stopOpacity={0}
/>
</linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis
axisLine={false}
tickLine={false}
@@ -141,24 +235,99 @@ export const HistogramDetails = ({
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart>
);
};
@@ -8,7 +8,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors";
import { XTick } from "@/Components/monitors/charts/XTick";
import {
XAxis,
AreaChart,
@@ -20,7 +20,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
const msg: Record<string, string> = {
success: "Excellent",
warning: "Average",
danger: "Poor",
error: "Poor",
};
return (
@@ -0,0 +1,30 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
+5 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -40,11 +40,13 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data),
type: "http",
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
};
break;
case "ping":
@@ -133,11 +135,13 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data),
type: "http",
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
customUpCodes: [],
};
}
+1
View File
@@ -31,6 +31,7 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
customDomain: data?.customDomain ?? null,
timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
@@ -10,8 +10,9 @@ import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
interface InviteResponse {
token: string;
@@ -56,7 +57,7 @@ export const InviteTeamMemberDialog = ({
const result = await generateToken("/invite", data);
if (result?.data?.token) {
const token = result.data.token;
const link = CLIENT_HOST ? `${CLIENT_HOST}/register/${token}` : token;
const link = `${CLIENT_HOST || window.location.origin}/register/${token}`;
setInviteLink(link);
}
};
+136 -14
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -41,8 +42,11 @@ import {
type Monitor,
type MonitorType,
type GamesMap,
type HttpMethod,
supportsGeoCheck,
DefaultPageSpeedStrategy,
DefaultHttpMethod,
HttpMethods,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { Tag } from "@/Types/Tag";
@@ -258,7 +262,7 @@ const CreateMonitorPage = () => {
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control, watch, handleSubmit, clearErrors, trigger, reset } = form;
const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form;
useEffect(() => {
reset(defaults);
@@ -270,6 +274,7 @@ const CreateMonitorPage = () => {
const showStep = (step: number) => isEditMode || currentStep === step;
const watchedType = watch("type") as MonitorType;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
@@ -1091,27 +1096,144 @@ const CreateMonitorPage = () => {
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="useAdvancedMatching"
name="method"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Select
{...field}
value={field.value ?? DefaultHttpMethod}
onChange={(e) => {
const value = e.target.value as HttpMethod;
field.onChange(value);
// HEAD has no response body, reset advanced matching fields if selected
if (value === "HEAD") {
setValue("useAdvancedMatching", false);
setValue("matchMethod", "");
setValue("expectedValue", "");
setValue("jsonPath", "");
}
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.method.label"
)}
>
{HttpMethods.map((method) => (
<MenuItem
key={method}
value={method}
>
{method}
</MenuItem>
))}
</Select>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
`pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}`
)}
</Typography>
</Stack>
)}
/>
{watchedUseAdvancedMatching && (
<Controller
name="customUpCodes"
control={control}
render={({ field }) => {
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
field.value?.includes(option.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={ALL_HTTP_STATUS_CODES}
value={selectedOptions}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(
_: unknown,
newValue: typeof ALL_HTTP_STATUS_CODES
) => {
field.onChange(newValue?.map((v) => v.id) || []);
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.customUpCodes.label"
)}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.customUpCodes.description"
)}
</Typography>
{selectedOptions.length > 0 && (
<Stack
flex={1}
gap={theme.spacing(SPACING.MD)}
width="100%"
>
{selectedOptions.map((option, index) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
<Typography flexGrow={1}>{option.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value?.filter((id) => id !== option.id)
);
}}
aria-label={t(
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
)}
>
<Trash2 size={16} />
</IconButton>
{index < selectedOptions.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
{watchedMethod !== "HEAD" && (
<Controller
name="useAdvancedMatching"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
)}
</Typography>
</Stack>
)}
/>
)}
{watchedUseAdvancedMatching && watchedMethod !== "HEAD" && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="matchMethod"
@@ -63,10 +63,20 @@ const getChartConfigs = (
return configs;
};
const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
checks.some((c) =>
c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
);
// Loopback / bridge names Capture sees when it can only observe the container's
// own network namespace. When ALL reported interfaces match this set we assume
// the "no real interfaces visible" case and surface the notice.
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]);
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) return false;
for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
}
return true;
};
export const InfraNetworkCharts = ({
checks,
@@ -82,13 +92,14 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
const showLoopbackOnlyNotice =
chartConfigs.length > 0 && onlyContainerVisibleInterfaces(checks);
return (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{showNoTrafficNotice && (
{showLoopbackOnlyNotice && (
<NoticeBanner severity="warning">
{t("pages.infrastructure.charts.labels.netNoTraffic")}
{t("pages.infrastructure.charts.labels.loopbackOnlyNotice")}
</NoticeBanner>
)}
<Grid
@@ -25,10 +25,11 @@ export const InfraDetailsGauges = ({
<Stack
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"center"}
alignItems={"stretch"}
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -37,6 +38,7 @@ export const InfraDetailsGauges = ({
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -49,6 +51,7 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
@@ -33,6 +33,26 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => {
title={t("pages.logs.diagnostics.mongoDBStats.readyState")}
subtitle={mongoStats.readyState.toString()}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.readsPerSecond")}
subtitle={mongoStats.readsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.insertsPerSecond")}
subtitle={mongoStats.insertsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.updatesPerSecond")}
subtitle={mongoStats.updatesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.deletesPerSecond")}
subtitle={mongoStats.deletesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.writesPerSecond")}
subtitle={mongoStats.writesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.host")}
subtitle={mongoStats.host}
+18 -41
View File
@@ -42,12 +42,15 @@ export const TableJobs = ({
maxWidth: 220,
};
const isRunning = (row: QueueJobWithId) =>
row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now();
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
if (isRunning(row)) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
@@ -72,18 +75,6 @@ export const TableJobs = ({
</Typography>
),
},
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
},
{
id: "type",
content: t("common.table.headers.type"),
@@ -92,50 +83,36 @@ export const TableJobs = ({
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
id: "lastFinishedAt",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-";
return (
<Typography
title={v}
title={lastFinishedAt}
sx={cellSx}
>
{v}
{lastFinishedAt}
</Typography>
);
},
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
id: "nextScheduledAt",
content: t("pages.logs.table.headers.nextScheduledAt"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography sx={cellSx}>{value}</Typography>;
},
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-";
return (
<Typography
title={v}
title={nextScheduledAt}
sx={cellSx}
>
{v}
{nextScheduledAt}
</Typography>
);
},
@@ -169,7 +146,7 @@ export const TableJobs = ({
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
...(isRunning(row) && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
+1 -2
View File
@@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
const responseTime = Math.floor(Math.random() * 800) + 20;
checks.push({
id: `dummy-${i}`,
status: isUp,
statusCode: isUp ? 200 : 500,
responseTime: responseTime,
originalResponseTime: responseTime,
message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(),
});
@@ -151,12 +151,18 @@ const CreateStatusPage = () => {
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
if (data.customDomain !== undefined && data.customDomain !== null) {
fd.append("customDomain", data.customDomain);
} else {
fd.append("customDomain", "");
}
if (data.timezone) fd.append("timezone", data.timezone);
if (data.color) fd.append("color", data.color);
fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("customCSS", data.customCSS ?? "");
if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode);
@@ -284,6 +290,27 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
@@ -504,6 +531,33 @@ const CreateStatusPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
@@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
window.open(
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
getStatusPagePublicUrl(statusPage),
"_blank",
"noopener,noreferrer"
);
+45 -14
View File
@@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -14,6 +15,13 @@ import {
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { cssReferencesExternalResource } from "@/Utils/customCss";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -33,6 +41,7 @@ import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
import { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
@@ -58,6 +67,11 @@ const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
minimal: {
createStyles: minimalStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
};
const StatusPageView = () => {
@@ -67,9 +81,14 @@ const StatusPageView = () => {
const isAdmin = useIsAdmin();
const location = useLocation();
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const onCustomDomainHost = isCustomDomainHost();
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const apiUrl = buildStatusPageApiPath({
url,
useCustomDomain: onCustomDomainHost,
});
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
@@ -111,30 +130,33 @@ const StatusPageView = () => {
);
}
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const customCss =
statusPage.customCSS && !cssReferencesExternalResource(statusPage.customCSS)
? statusPage.customCSS
: "";
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
brandColor={statusPage.color}
paintBody
>
{themedRenderer}
{customCss && <style>{customCss}</style>}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider>
);
}
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
const publicUrl = getStatusPagePublicUrl(statusPage);
const previewUrl = getStatusPagePreviewUrl(statusPage);
return (
<BasePage
loading={isLoading}
@@ -153,7 +175,16 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
<BrowserFrame url={publicUrl}>
<Box
component="iframe"
src={previewUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
@@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => {
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
<Box
display="flex"
flexDirection="column"
sx={{ flex: 1, minHeight: 0 }}
>
{children}
</Box>
</Box>
);
};
@@ -6,6 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
import { alpha, darken } from "@mui/material/styles";
import {
DEFAULT_STATUS_PAGE_THEME,
resolveStatusPageTheme,
@@ -36,6 +37,15 @@ interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
timezone?: string;
/**
* Brand color picked in the status-page appearance settings. When
* provided, it feeds `tokens.brand` / `brandStrong` / `brandSoft`,
* which theme styles use for accent surfaces (hero, active tabs,
* links). It does NOT recolor the semantic `up`/OK status color, so
* a red or orange brand cannot make the "all operational" indicator
* signal downtime.
*/
brandColor?: string;
/**
* When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route
@@ -54,10 +64,32 @@ interface Props {
children: ReactNode;
}
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
// Resolve the brand triple that theme styles read from. A customer's
// picked color (when valid) overrides the theme's default; otherwise
// the theme's own `brand*` values win, and finally we fall back to the
// `up*` triple so unmigrated theme styles have something to consume.
const resolveBrandTokens = (
tokens: StatusPageThemeTokens,
brandColor: string | undefined
): StatusPageThemeTokens => {
const validOverride = brandColor && HEX_COLOR.test(brandColor) ? brandColor : undefined;
const brand = validOverride ?? tokens.brand ?? tokens.up;
const brandStrong = validOverride
? darken(validOverride, 0.2)
: (tokens.brandStrong ?? tokens.upStrong);
const brandSoft = validOverride
? alpha(validOverride, 0.15)
: (tokens.brandSoft ?? tokens.upSoft);
return { ...tokens, brand, brandStrong, brandSoft };
};
export const StatusPageThemeProvider = ({
theme,
themeMode,
timezone,
brandColor,
paintBody = false,
transparent = false,
children,
@@ -109,7 +141,11 @@ export const StatusPageThemeProvider = ({
const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const tokens = themeTokens[resolvedTheme][resolvedMode];
const baseTokens = themeTokens[resolvedTheme][resolvedMode];
const tokens = useMemo(
() => resolveBrandTokens(baseTokens, brandColor),
[baseTokens, brandColor]
);
const value = useMemo(
() => ({
@@ -99,9 +99,9 @@ export const boldStyles = (
},
top: {
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: "12px",
mb: "36px",
},
brand: {
@@ -130,7 +130,7 @@ export const boldStyles = (
hero: {
position: "relative",
borderRadius: "22px",
padding: "36px 36px 32px",
padding: { xs: "24px 20px", md: "36px 36px 32px" },
mb: "28px",
background: isDark ? heroBgDark : heroBgLight,
border: `1px solid ${tokens.border}`,
@@ -149,13 +149,14 @@ export const boldStyles = (
},
heroTitle: {
m: 0,
fontSize: 34,
fontSize: { xs: 24, md: 34 },
fontWeight: 800,
letterSpacing: "-0.02em",
lineHeight: 1.1,
color: tokens.text,
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
gap: "10px",
},
heroCheck: (tone) => ({
@@ -171,9 +172,15 @@ export const boldStyles = (
}),
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "16px",
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
@@ -181,16 +188,20 @@ export const boldStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "8px 18px",
padding: { xs: "8px 6px", md: "8px 18px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.12em",
letterSpacing: { xs: "0.04em", md: "0.12em" },
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
@@ -217,13 +228,21 @@ export const boldStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "24px",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "24px" },
rowGap: { xs: "10px", md: "6px" },
p: "22px 28px",
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 700,
fontSize: 17,
letterSpacing: "-0.01em",
@@ -233,10 +252,9 @@ export const boldStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gridArea: "meta",
gap: "10px",
alignItems: "center",
mt: "6px",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
},
pill: {
@@ -273,6 +291,8 @@ export const boldStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 800,
padding: "6px 14px",
@@ -296,10 +316,10 @@ export const boldStyles = (
}),
heatmap: {
padding: "0 28px 22px",
padding: { xs: "0 12px 18px", md: "0 28px 22px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind) => ({
@@ -312,10 +332,10 @@ export const boldStyles = (
}),
histogram: {
padding: "0 28px",
padding: { xs: "0 12px", md: "0 28px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
alignItems: "flex-end",
height: 48,
},
@@ -90,9 +90,9 @@ export const editorialStyles = (
},
top: {
display: "flex",
alignItems: "flex-end",
alignItems: { xs: "flex-start", md: "flex-end" },
justifyContent: "space-between",
gap: "12px",
mb: "48px",
borderBottom: `2px solid ${tokens.text}`,
pb: "18px",
@@ -119,7 +119,7 @@ export const editorialStyles = (
},
statusLine: {
fontSize: 22,
fontSize: { xs: 18, md: 22 },
fontWeight: 400,
m: 0,
mb: "8px",
@@ -144,26 +144,32 @@ export const editorialStyles = (
letterSpacing: "0.12em",
color: tokens.textMuted,
m: 0,
mb: "56px",
mb: { xs: "32px", md: "56px" },
},
chartSwitchWrap: {
display: "flex",
justifyContent: "flex-end",
justifyContent: { xs: "center", md: "flex-end" },
mb: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.text}`,
"& > button + button": { borderLeft: `1px solid ${tokens.text}` },
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
fontFamily: "inherit",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: "0.2em",
padding: "6px 14px",
letterSpacing: { xs: "0.05em", md: "0.2em" },
padding: { xs: "6px 6px", md: "6px 14px" },
border: 0,
background: active ? tokens.text : "transparent",
color: active ? tokens.bg : tokens.text,
@@ -188,12 +194,20 @@ export const editorialStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "baseline",
gap: "20px",
alignItems: { xs: "start", md: "baseline" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontSize: 20,
fontWeight: 700,
letterSpacing: "-0.015em",
@@ -203,14 +217,13 @@ export const editorialStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
gridArea: "meta",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12,
color: tokens.textMuted,
mt: "6px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
},
pill: {
textTransform: "uppercase",
@@ -234,6 +247,8 @@ export const editorialStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10,
fontWeight: 700,
@@ -250,7 +265,7 @@ export const editorialStyles = (
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind) => ({
@@ -262,7 +277,7 @@ export const editorialStyles = (
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "2px",
gap: { xs: "1px", md: "2px" },
alignItems: "flex-end",
height: 40,
},
@@ -0,0 +1,66 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import type { OverallTone } from "../shared/overallStatus";
import { refinedStyles, type RefinedStyles } from "../refined/styles";
type FlatSx = Exclude<SxProps<Theme>, readonly unknown[] | ((theme: Theme) => unknown)>;
const extend = (base: SxProps<Theme>, overrides: SxProps<Theme>): SxProps<Theme> => [
base as FlatSx,
overrides as FlatSx,
];
export const minimalStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): RefinedStyles => {
const base = refinedStyles(tokens, isDark);
return {
...base,
page: extend(base.page, {
maxWidth: 820,
p: { xs: "32px 16px 64px", md: "48px 20px 72px" },
}),
top: extend(base.top, {
mb: "44px",
pb: "16px",
borderBottom: `1px solid ${tokens.border}`,
}),
logoMono: extend(base.logoMono, {
borderRadius: "50%",
background: tokens.text,
}),
hero: extend(base.hero, {
alignItems: "center",
background: "transparent",
border: 0,
boxShadow: "none",
p: 0,
mb: "40px",
}),
heroRow: extend(base.heroRow, { justifyContent: "center" }),
heroTitle: extend(base.heroTitle, {
flex: "initial",
fontSize: { xs: 22, md: 28 },
fontWeight: 650,
textAlign: "center",
}),
heroIcon: (_tone: OverallTone) => ({ display: "none" }),
heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }),
chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }),
card: extend(base.card, {
boxShadow: "none",
transition: "none",
"&:hover": { transform: "none", boxShadow: "none" },
}),
cardRow: extend(base.cardRow, { p: "14px 16px" }),
badge: (tone) =>
extend(base.badge(tone), {
background: "transparent",
p: 0,
}),
gauge: extend(base.gauge, { borderRadius: tokens.radius }),
footer: extend(base.footer, { mt: "48px", fontSize: 11 }),
};
};
@@ -11,24 +11,24 @@ export const ModernHero = ({
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box sx={styles.heroRow}>
<Box sx={styles.pulse(overall.tone)} />
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -17,7 +17,7 @@ export interface ModernStyles {
logoImg: SxProps<Theme>;
hero: SxProps<Theme>;
pulse: (tone: OverallTone) => SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroRow: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -117,9 +117,9 @@ export const modernStyles = (
},
top: {
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: "12px",
mb: "32px",
},
brand: {
@@ -148,11 +148,11 @@ export const modernStyles = (
hero: {
position: "relative",
borderRadius: "20px",
padding: "28px 32px",
padding: { xs: "20px", md: "28px 32px" },
mb: "24px",
display: "flex",
alignItems: "center",
gap: "20px",
flexDirection: "column",
gap: "4px",
background: tokens.surface,
border: `1px solid ${tokens.border}`,
boxShadow: cardShadow,
@@ -182,25 +182,37 @@ export const modernStyles = (
},
};
},
statusCopy: { flex: 1, minWidth: 0 },
heroRow: {
display: "flex",
alignItems: "center",
gap: { xs: "14px", md: "20px" },
},
heroTitle: {
m: 0,
mb: "4px",
fontSize: 22,
flex: 1,
minWidth: 0,
fontSize: { xs: 18, md: 22 },
fontWeight: 700,
letterSpacing: "-0.02em",
color: tokens.text,
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
flexShrink: 0,
}),
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "14px",
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
@@ -208,16 +220,20 @@ export const modernStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "6px 16px",
padding: { xs: "6px 6px", md: "6px 16px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
letterSpacing: { xs: "0.04em", md: "0.08em" },
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
@@ -256,13 +272,21 @@ export const modernStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "20px",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
p: "18px 24px",
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600,
fontSize: 15,
letterSpacing: "-0.005em",
@@ -272,10 +296,9 @@ export const modernStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gridArea: "meta",
gap: "10px",
alignItems: "center",
mt: "6px",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
},
pill: {
@@ -299,6 +322,8 @@ export const modernStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 700,
padding: "5px 12px",
@@ -319,10 +344,10 @@ export const modernStyles = (
}),
heatmap: {
padding: "0 24px 20px",
padding: { xs: "0 12px 16px", md: "0 24px 20px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind) => ({
@@ -334,10 +359,10 @@ export const modernStyles = (
}),
histogram: {
padding: "0 24px",
padding: { xs: "0 12px", md: "0 24px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
alignItems: "flex-end",
height: 46,
},
@@ -11,24 +11,24 @@ export const RefinedHero = ({
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box sx={styles.heroRow}>
<Box sx={styles.statusDot(overall.tone)} />
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -17,7 +17,7 @@ export interface RefinedStyles {
company: SxProps<Theme>;
hero: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroRow: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -102,9 +102,9 @@ export const refinedStyles = (
},
top: {
display: "flex",
alignItems: "center",
alignItems: { xs: "flex-start", md: "center" },
justifyContent: "space-between",
gap: "12px",
mb: "28px",
},
brand: {
@@ -119,7 +119,7 @@ export const refinedStyles = (
width: 28,
height: 28,
borderRadius: "8px",
background: tokens.up,
background: tokens.brand,
display: "grid",
placeItems: "center",
color: "#fff",
@@ -133,10 +133,10 @@ export const refinedStyles = (
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radius,
padding: "22px 24px",
padding: { xs: "18px 20px", md: "22px 24px" },
display: "flex",
alignItems: "center",
gap: "16px",
flexDirection: "column",
gap: "4px",
boxShadow: cardShadow,
mb: "20px",
},
@@ -148,10 +148,15 @@ export const refinedStyles = (
boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
flexShrink: 0,
}),
statusCopy: { flex: 1, minWidth: 0 },
heroRow: {
display: "flex",
alignItems: "center",
gap: { xs: "12px", md: "16px" },
},
heroTitle: {
m: 0,
mb: "2px",
flex: 1,
minWidth: 0,
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
@@ -159,14 +164,21 @@ export const refinedStyles = (
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
flexShrink: 0,
}),
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "12px",
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "8px",
background: tokens.surface,
@@ -174,17 +186,21 @@ export const refinedStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
background: active ? tokens.brandSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: "5px 14px",
padding: { xs: "5px 6px", md: "5px 14px" },
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
color: active ? tokens.brand : tokens.textMuted,
borderRadius: "5px",
transition: "background 0.15s ease, color 0.15s ease",
fontWeight: active ? 600 : 500,
"&:hover": { color: active ? tokens.up : tokens.text },
"&:hover": { color: active ? tokens.brand : tokens.text },
}),
monitorList: {
@@ -207,13 +223,21 @@ export const refinedStyles = (
},
cardRow: {
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "16px",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "16px" },
rowGap: { xs: "10px", md: "4px" },
p: "16px 20px",
},
cardLeft: { minWidth: 0 },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600,
fontSize: 14,
letterSpacing: "-0.005em",
@@ -223,10 +247,9 @@ export const refinedStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
display: "flex",
gridArea: "meta",
gap: "10px",
alignItems: "center",
mt: "4px",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
},
pill: {
@@ -236,9 +259,9 @@ export const refinedStyles = (
},
pillHardware: {
...pillBase,
color: tokens.up,
color: tokens.brand,
border: `1px solid ${tokens.border}`,
background: tokens.upSoft,
background: tokens.brandSoft,
},
monitorUrl: {
fontSize: 12,
@@ -251,6 +274,8 @@ export const refinedStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 600,
padding: "4px 10px",
@@ -261,10 +286,10 @@ export const refinedStyles = (
}),
heatmap: {
padding: "0 20px 16px",
padding: { xs: "0 10px 14px", md: "0 20px 16px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind) => ({
@@ -276,10 +301,10 @@ export const refinedStyles = (
}),
histogram: {
padding: "0 20px",
padding: { xs: "0 10px", md: "0 20px" },
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: "3px",
gap: { xs: "1px", md: "3px" },
alignItems: "flex-end",
height: 42,
},
@@ -357,11 +382,11 @@ export const refinedStyles = (
fontSize: 12,
mt: "40px",
"& a": {
color: tokens.up,
color: tokens.brand,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 600,
"&:hover": { color: tokens.upStrong || tokens.up },
"&:hover": { color: tokens.brandStrong },
},
},
};
@@ -27,8 +27,9 @@ import {
statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
type StatusPageMonitor = Monitor;
export interface BaseStyles {
page: SxProps<Theme>;
@@ -101,8 +102,10 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
return (
<Box sx={styles.page}>
<Box
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
<Stack
component="header"
direction={{ xs: "column", md: "row" }}
sx={styles.top}
>
<HeaderSlot
@@ -112,7 +115,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
monitorCount={monitors.length}
styles={styles}
/>
</Box>
</Stack>
<HeroSlot
statusPage={statusPage}
@@ -161,6 +164,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
return (
<Box
@@ -169,32 +173,39 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
<Stack
direction={{ xs: "column", md: "row" }}
sx={styles.monitorMeta}
>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
<Box
component="span"
sx={{ ...styles.pill }}
>
{uptimePercentage}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Stack>
</Box>
{showInfra && (
@@ -0,0 +1,30 @@
import type { CheckSnapshot } from "@/Types/Check";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import prettyMilliseconds from "pretty-ms";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { useTranslation } from "react-i18next";
export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
return (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${prettyMilliseconds(check.responseTime, { compact: true })}`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
};
@@ -1,13 +1,10 @@
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
const CELLS = MAX_RECENT_CHECKS;
@@ -29,7 +26,6 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
@@ -53,24 +49,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
);
}
const kind = classify(check);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
</Typography>
</Stack>
);
const tooltipContent = <ThemedChartTooltip check={check} />;
return (
<Tooltip
key={check.id ?? i}
@@ -2,13 +2,12 @@ import { useMemo } from "react";
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { computeBarHeights } from "@/Utils/DataUtils";
import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
const CELLS = MAX_RECENT_CHECKS;
const MIN_HEIGHT_PCT = 6;
@@ -33,20 +32,20 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const { padded, max, avg, peak } = useMemo(() => {
const { padded, heights, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS);
const out: (CheckSnapshot | null)[] = [
...source,
...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
];
const heights = computeBarHeights(source);
const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
const avgRt = valid.length
? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
: 0;
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
return { padded: out, heights, avg: avgRt, peak: maxRt };
}, [checks]);
return (
@@ -61,32 +60,8 @@ export const ThemedHistogram = ({
/>
);
}
const height = Math.max(
MIN_HEIGHT_PCT,
Math.round((check.responseTime / max) * 100)
);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
timezone
)}
</Typography>
</Stack>
);
const height = heights[i] ?? MIN_HEIGHT_PCT;
const tooltipContent = <ThemedChartTooltip check={check} />;
return (
<Tooltip
key={check.id ?? i}
@@ -18,7 +18,7 @@ export interface InfraSx {
}
interface Props {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
monitor: Monitor;
sxApi: InfraSx;
}
@@ -36,7 +36,7 @@ const heatLevel = (value: number): GaugeFillLevel =>
export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation();
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
const latest = monitor.recentChecks?.[0];
const renderEmpty = () => (
<Box sx={sxApi.emptySx}>{t("pages.statusPages.monitorsList.noData")}</Box>
@@ -9,6 +9,14 @@ export interface StatusPageThemeTokens {
up: string;
upStrong: string;
upSoft: string;
// Accent used for brand-tinted surfaces (hero background, active tab
// underline, "view all" links). Distinct from `up` so a red/orange
// brand color cannot recolor the semantic OK/up status indicators.
// Themes leave these undefined by default; the provider falls back
// to `up`/`upStrong`/`upSoft` so styles have a value to consume.
brand?: string;
brandStrong?: string;
brandSoft?: string;
degraded: string;
degradedSoft: string;
down: string;
@@ -191,9 +199,47 @@ const editorial: ThemeVariants = {
},
};
const minimal: ThemeVariants = {
light: {
bg: "#ffffff",
surface: "#ffffff",
border: "#dfe3e8",
text: "#17191c",
textMuted: "#667085",
up: "#12a174",
upStrong: "#087a57",
upSoft: "#e7f7f1",
degraded: "#b54708",
degradedSoft: "#fff1dc",
down: "#c4323f",
downSoft: "#fde8ea",
warn: "#c27803",
warnSoft: "#fff3d6",
radius: "6px",
},
dark: {
bg: "#0d0f12",
surface: "#111419",
border: "#2b3038",
text: "#f2f4f7",
textMuted: "#98a2b3",
up: "#32d6a0",
upStrong: "#1eb783",
upSoft: "rgba(50,214,160,0.14)",
degraded: "#f0a44b",
degradedSoft: "rgba(240,164,75,0.16)",
down: "#ff6675",
downSoft: "rgba(255,102,117,0.16)",
warn: "#f2b84b",
warnSoft: "rgba(242,184,75,0.16)",
radius: "6px",
},
};
export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
refined,
modern,
bold,
editorial,
minimal,
};
@@ -11,7 +11,9 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
import { type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
@@ -53,8 +55,7 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer");
}
};
@@ -70,7 +71,9 @@ export const StatusPagesTable = ({
content: t("pages.statusPages.table.headers.url"),
render: (row) => {
const content = row.isPublished
? `/${row.url}`
? row.customDomain
? getStatusPagePublicPath(row)
: `/${row.url}`
: t("pages.statusPages.table.unpublished");
return (
<Stack
+11 -3
View File
@@ -57,7 +57,6 @@ const UptimeDetailsPage = () => {
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
params.append("normalize", "true");
return `/monitors/uptime/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]);
@@ -72,6 +71,7 @@ const UptimeDetailsPage = () => {
);
const monitorData = monitorDetailsData?.monitorData;
const monitor = monitorData?.monitor;
const monitorStats = monitorDetailsData?.monitorStats ?? null;
@@ -165,10 +165,18 @@ const UptimeDetailsPage = () => {
{ keepPreviousData: true, revalidateOnFocus: false }
);
if (!monitorData) {
return null;
}
if (!monitor) {
return null;
}
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor?.geoCheckLocations;
const geoLocations = monitor.geoCheckLocations;
const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0;
@@ -215,7 +223,7 @@ const UptimeDetailsPage = () => {
/>
</Stack>
<HistogramDetails
checks={monitorData?.groupedChecks || []}
checks={monitorData.groupedChecks}
range={dateRange}
/>
<ChecksTable
@@ -25,6 +25,8 @@ import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps {
monitors: Monitor[];
@@ -259,6 +261,24 @@ export const MonitorTable = ({
}
},
},
{
id: "uptime",
content: t("pages.uptime.table.headers.uptime"),
render: (row) => {
const uptimePercentage = row.uptimePercentage ?? 0;
const formattedPercentage = formatPercentage(uptimePercentage);
const paletteKey = getUptimePercentageColor(uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<Stack alignItems={"center"}>
<ColoredLabel
text={formattedPercentage}
color={color}
/>
</Stack>
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
@@ -304,7 +324,9 @@ export const MonitorTable = ({
</Stack>
),
render: (row) => {
return row.type;
return row.type === "http" && row.method
? `${row.type} (${row.method})`
: row.type;
},
},
{
+16
View File
@@ -54,8 +54,24 @@ import Logs from "@/Pages/Logs";
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => {
if (isCustomDomainHost()) {
return (
<LibRoutes>
<Route
path="/"
element={<Status />}
/>
<Route
path="*"
element={<Status />}
/>
</LibRoutes>
);
}
return (
<LibRoutes>
<Route
+10 -12
View File
@@ -149,6 +149,15 @@ export interface GroupedCheck {
totalChecks: number;
}
export interface GroupedUptimeCheck extends GroupedCheck {
avgDns: number;
avgTcp: number;
avgTls: number;
avgRequest: number;
avgFirstByte: number;
avgDownload: number;
}
export interface PageSpeedGroupedCheck {
bucketDate: string;
performance: number;
@@ -241,18 +250,7 @@ export interface ChecksSummary {
downChecks: number;
}
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
originalResponseTime: number;
};
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt">;
export interface HasResponseTime {
responseTime: number;
}
export type NormalizedCheck<T extends HasResponseTime = Check> = T & {
originalResponseTime: number;
};
export type NormalizedUptimeCheck<T extends GroupedCheck = GroupedCheck> = T & {
originalAvgResponseTime: number;
};
+5
View File
@@ -23,6 +23,11 @@ export interface V8HeapStats {
export interface MongoStats {
readyState: number;
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
host: string;
port: number;
dbName: string;
+8 -2
View File
@@ -1,4 +1,4 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
@@ -48,6 +48,10 @@ export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export interface Monitor {
id: string;
userId: string;
@@ -64,6 +68,7 @@ export interface Monitor {
jsonPath?: string;
expectedValue?: string;
matchMethod?: MonitorMatchMethod;
method?: HttpMethod;
url: string;
port?: number;
isActive: boolean;
@@ -71,6 +76,7 @@ export interface Monitor {
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes?: number[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
@@ -131,7 +137,7 @@ export interface MonitorStats {
export interface MonitorData {
monitor: Monitor;
groupedChecks: GroupedCheck[];
groupedChecks: GroupedUptimeCheck[];
groupedUpChecks: GroupedCheck[];
groupedDownChecks: GroupedCheck[];
groupedAvgResponseTime: number;
+1 -8
View File
@@ -30,23 +30,16 @@ export type QueueMetrics = {
export type QueueJobSummary = {
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedBy: string | null;
lockedUntil: number | null;
lockedAt: number | null;
nextScheduledAt: number;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export interface QueueData {
+8 -1
View File
@@ -21,7 +21,13 @@ export const getMonitorTypeLabel = (
return key ? t(key) : type;
};
export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
export const STATUS_PAGE_THEMES = [
"refined",
"modern",
"bold",
"editorial",
"minimal",
] as const;
export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
@@ -47,6 +53,7 @@ export interface StatusPage {
type: MonitorDisplayType[];
companyName: string;
url: string;
customDomain?: string | null;
timezone?: string;
color: string;
monitors: string[];
+2 -1
View File
@@ -3,7 +3,8 @@
declare const __APP_VERSION__: string;
interface ImportMetaEnv {
readonly VITE_APP_API_V2_BASE_URL?: string;
readonly VITE_APP_API_BASE_URL?: string;
readonly VITE_APP_CLIENT_HOST?: string;
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
}
+3 -1
View File
@@ -1,8 +1,10 @@
import axios from "axios";
import type { AxiosError } from "axios";
import type { AxiosRequestConfig, AxiosResponse } from "axios";
import { runtimeConfig } from "@/Utils/runtimeConfig";
const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
const BASE_URL =
runtimeConfig.apiBaseUrl || import.meta.env.VITE_APP_API_BASE_URL || "/api/v1";
const api = axios.create({
baseURL: BASE_URL,
+29 -44
View File
@@ -1,47 +1,3 @@
const MIN_OUT = 10;
const MAX_OUT = 100;
export const normalizeResponseTimes = <T, K extends keyof T>(
checks: T[],
key: K
): (T & { normalResponseTime: number })[] => {
if (!Array.isArray(checks) || checks.length === 0)
return checks as (T & {
normalResponseTime: number;
})[];
if (checks.length === 1) {
return [
{
...checks[0],
normalResponseTime: 50,
},
];
}
const getVal = (check: T): number => {
const v = check[key] as unknown;
return typeof v === "number" && Number.isFinite(v) ? v : 0;
};
const { min, max } = checks.reduce(
(acc, check) => {
const v = getVal(check);
if (v > acc.max) acc.max = v;
if (v < acc.min) acc.min = v;
return acc;
},
{ max: -Infinity, min: Infinity }
);
const range = max - min || 1;
return checks.map((check) => ({
...check,
normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range,
}));
};
// Interpolate color between three theme colors over 0-100 range.
// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light)
export const getResponseColor = (
@@ -129,3 +85,32 @@ export const getResponseColor = (
}
return normalizeToHex(safe.end);
};
const MIN_HEIGHT_PCT = 8;
const CAP_PERCENTILE = 95;
const percentile = (sortedValues: number[], p: number): number => {
const index = (p / 100) * (sortedValues.length - 1);
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= sortedValues.length) return sortedValues[lower];
return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight;
};
export const computeBarHeights = (
checks: { responseTime?: number | null }[]
): number[] => {
const values = checks.map((check) => {
return typeof check.responseTime === "number" && Number.isFinite(check.responseTime)
? Math.max(0, check.responseTime)
: 0;
});
const sorted = values.slice().sort((a, b) => a - b);
const cap = percentile(sorted, CAP_PERCENTILE);
if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT);
return values.map((value) => {
return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100));
});
};
+10
View File
@@ -63,6 +63,16 @@ export const getResponseTimeColor = (responseTime: number): PaletteKey => {
}
};
export const getUptimePercentageColor = (uptimePercentage: number): PaletteKey => {
if (uptimePercentage >= 0.75) {
return "success";
} else if (uptimePercentage >= 0.5) {
return "warning";
} else {
return "error";
}
};
export const getInfraGaugeColor = (val: number, theme: any) => {
if (val < 50) {
return theme.palette.success.main;
+31
View File
@@ -20,8 +20,19 @@ export const colors = {
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
green700: "#008300",
yellow200: "#FFF4E5",
yellow500: "#EDA100",
yellow600: "#C98500",
orange500: "#FFA500",
orange600: "#EB6834",
orange700: "#D95926",
blue400: "#3987E5",
blue500: "#2A78D6",
aqua500: "#1BAF7A",
aqua600: "#199E70",
magenta300: "#E87BA4",
magenta400: "#D55181",
};
export const lightPalette = {
@@ -38,6 +49,16 @@ export const lightPalette = {
running: colors.green200,
paused: colors.yellow200,
},
chart: {
phases: {
dns: colors.magenta300,
tcp: colors.orange600,
tls: colors.aqua500,
request: colors.yellow500,
firstByte: colors.blue500,
download: colors.green700,
},
},
};
export const darkPalette = {
@@ -58,4 +79,14 @@ export const darkPalette = {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
},
chart: {
phases: {
dns: colors.magenta400,
tcp: colors.orange700,
tls: colors.aqua600,
request: colors.yellow600,
firstByte: colors.blue500,
download: colors.green700,
},
},
};
+20
View File
@@ -16,10 +16,30 @@ declare module "@mui/material/styles" {
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
chart: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
}
interface PaletteOptions {
sidebar?: { accent: string };
rowStatus?: { running: string; paused: string };
chart?: {
phases: {
dns: string;
tcp: string;
tls: string;
request: string;
firstByte: string;
download: string;
};
};
}
}
+16
View File
@@ -0,0 +1,16 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects
// the same CSS the API would.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}
return tokenize({ css }).some(
(token) =>
(isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)
);
};
+3 -1
View File
@@ -1,4 +1,5 @@
import type { LogLevel } from "@/Types/Log";
import { runtimeConfig } from "@/Utils/runtimeConfig";
interface LogContext {
[key: string]: any;
@@ -25,7 +26,8 @@ interface ErrorLogData {
userAgent: string;
}
const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredLevel: LogLevel =
runtimeConfig.logLevel || import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
const shouldLog = (level: LogLevel): boolean => {
+18
View File
@@ -0,0 +1,18 @@
import type { LogLevel } from "@/Types/Log";
// Served by GET /config.js (rendered from server env) or a static config.js
// next to index.html; loaded before the bundle in index.html.
export interface RuntimeConfig {
apiBaseUrl?: string;
clientHost?: string;
logLevel?: LogLevel;
}
declare global {
interface Window {
__CHECKMATE_CONFIG__?: RuntimeConfig;
}
}
export const runtimeConfig: RuntimeConfig =
(typeof window !== "undefined" && window.__CHECKMATE_CONFIG__) || {};
+93
View File
@@ -74,3 +74,96 @@ export const getStatusCodeValueType = (code: number): ValueType => {
if (code < 400) return "neutral";
return "negative";
};
export interface HttpStatusCodeOption {
id: number;
name: string;
}
/**
* Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts
*/
export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [
{ id: 100, name: "100 - Continue" },
{ id: 101, name: "101 - Switching Protocols" },
{ id: 102, name: "102 - Processing" },
{ id: 103, name: "103 - Early Hints" },
{ id: 200, name: "200 - OK" },
{ id: 201, name: "201 - Created" },
{ id: 202, name: "202 - Accepted" },
{ id: 203, name: "203 - Non-Authoritative Information" },
{ id: 204, name: "204 - No Content" },
{ id: 205, name: "205 - Reset Content" },
{ id: 206, name: "206 - Partial Content" },
{ id: 207, name: "207 - Multi-Status" },
{ id: 208, name: "208 - Already Reported" },
{ id: 226, name: "226 - IM Used" },
{ id: 300, name: "300 - Multiple Choices" },
{ id: 301, name: "301 - Moved Permanently" },
{ id: 302, name: "302 - Found" },
{ id: 303, name: "303 - See Other" },
{ id: 304, name: "304 - Not Modified" },
{ id: 305, name: "305 - Use Proxy" },
{ id: 307, name: "307 - Temporary Redirect" },
{ id: 308, name: "308 - Permanent Redirect" },
{ id: 400, name: "400 - Bad Request" },
{ id: 401, name: "401 - Unauthorized" },
{ id: 402, name: "402 - Payment Required" },
{ id: 403, name: "403 - Forbidden" },
{ id: 404, name: "404 - Not Found" },
{ id: 405, name: "405 - Method Not Allowed" },
{ id: 406, name: "406 - Not Acceptable" },
{ id: 407, name: "407 - Proxy Authentication Required" },
{ id: 408, name: "408 - Request Timeout" },
{ id: 409, name: "409 - Conflict" },
{ id: 410, name: "410 - Gone" },
{ id: 411, name: "411 - Length Required" },
{ id: 412, name: "412 - Precondition Failed" },
{ id: 413, name: "413 - Payload Too Large" },
{ id: 414, name: "414 - URI Too Long" },
{ id: 415, name: "415 - Unsupported Media Type" },
{ id: 416, name: "416 - Range Not Satisfiable" },
{ id: 417, name: "417 - Expectation Failed" },
{ id: 418, name: "418 - I'm a Teapot" },
{ id: 419, name: "419 - Page Expired (Laravel)" },
{ id: 420, name: "420 - Enhance Your Calm (Twitter)" },
{ id: 421, name: "421 - Misdirected Request" },
{ id: 422, name: "422 - Unprocessable Entity" },
{ id: 423, name: "423 - Locked" },
{ id: 424, name: "424 - Failed Dependency" },
{ id: 425, name: "425 - Too Early" },
{ id: 426, name: "426 - Upgrade Required" },
{ id: 428, name: "428 - Precondition Required" },
{ id: 429, name: "429 - Too Many Requests" },
{ id: 431, name: "431 - Request Header Fields Too Large" },
{ id: 440, name: "440 - Login Time-out (IIS)" },
{ id: 449, name: "449 - Retry With (IIS)" },
{ id: 451, name: "451 - Unavailable For Legal Reasons" },
{ id: 460, name: "460 - Client Closed Connection (AWS ELB)" },
{ id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" },
{ id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" },
{ id: 499, name: "499 - Client Closed Request (NGINX)" },
{ id: 500, name: "500 - Internal Server Error" },
{ id: 501, name: "501 - Not Implemented" },
{ id: 502, name: "502 - Bad Gateway" },
{ id: 503, name: "503 - Service Unavailable" },
{ id: 504, name: "504 - Gateway Timeout" },
{ id: 505, name: "505 - HTTP Version Not Supported" },
{ id: 506, name: "506 - Variant Also Negotiates" },
{ id: 507, name: "507 - Insufficient Storage" },
{ id: 508, name: "508 - Loop Detected" },
{ id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" },
{ id: 510, name: "510 - Not Extended" },
{ id: 511, name: "511 - Network Authentication Required" },
{ id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" },
{ id: 521, name: "521 - Web Server Is Down (Cloudflare)" },
{ id: 522, name: "522 - Connection Timed Out (Cloudflare)" },
{ id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" },
{ id: 524, name: "524 - A Timeout Occurred (Cloudflare)" },
{ id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" },
{ id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" },
{ id: 527, name: "527 - Railgun Error (Cloudflare)" },
{ id: 529, name: "529 - Site is overloaded" },
{ id: 530, name: "530 - Site is frozen (Cloudflare)" },
{ id: 561, name: "561 - Unauthorized (AWS ELB)" },
];
+102
View File
@@ -0,0 +1,102 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { runtimeConfig } from "@/Utils/runtimeConfig";
const CLIENT_HOST =
runtimeConfig.clientHost ||
(import.meta.env.VITE_APP_CLIENT_HOST as string | undefined);
const getClientOriginProtocol = (): string => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return "https:";
}
try {
return new URL(source).protocol;
} catch {
return "https:";
}
};
const buildCustomDomainOrigin = (customDomain: string): string =>
`${getClientOriginProtocol()}//${customDomain}`;
export const getAppHostname = (): string | null => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return null;
}
try {
return new URL(source).hostname.toLowerCase();
} catch {
return null;
}
};
export const isCustomDomainHost = (): boolean => {
if (typeof window === "undefined") {
return false;
}
const appHostname = getAppHostname();
if (!appHostname) {
return false;
}
return window.location.hostname.toLowerCase() !== appHostname;
};
export const getStatusPagePublicPath = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const getStatusPagePublicUrl = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
// Same-origin URL for the admin preview iframe; a custom domain would be cross
// origin and blocked by the admin document's CSP frame-src.
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const buildStatusPageApiPath = (options: {
url?: string;
useCustomDomain?: boolean;
}): string | null => {
const query = "type=uptime&type=infrastructure";
if (options.useCustomDomain && typeof window !== "undefined") {
const domain = encodeURIComponent(window.location.hostname);
return `/status-page/resolve?domain=${domain}&${query}`;
}
if (options.url) {
return `/status-page/${options.url}?${query}`;
}
return null;
};
+17 -1
View File
@@ -1,7 +1,13 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { GeoContinents } from "@/Types/GeoCheck";
import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor";
import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
@@ -53,9 +59,15 @@ const geoCheckFields = {
};
// HTTP monitor schema
const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id));
const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), {
message: "Must be a valid HTTP status code",
});
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
matchMethod: z
@@ -64,6 +76,10 @@ const httpSchema = baseSchema.extend({
.register(monitorStepRegistry, { step: 2 }),
expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }),
jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }),
customUpCodes: z
.array(httpStatusCode)
.optional()
.register(monitorStepRegistry, { step: 2 }),
...geoCheckFields,
});
+32 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
import { cssReferencesExternalResource } from "@/Utils/customCss";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
@@ -9,6 +10,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"
// field definitions.
export const statusPageStepRegistry = z.registry<{ step: number }>();
const statusPageHostnameRegex =
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const normalizeCustomDomainInput = (raw: string | null): string | null => {
if (raw === null) {
return null;
}
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return null;
}
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
return withoutScheme.split("/")[0]?.split(":")[0] ?? null;
};
export const statusPageSchema = z.object({
companyName: z
.string()
@@ -22,6 +40,12 @@ export const statusPageSchema = z.object({
/^[a-z0-9-]+$/,
"URL can only contain lowercase letters, numbers, and hyphens"
),
customDomain: z
.union([z.string(), z.null()])
.transform(normalizeCustomDomainInput)
.refine((domain) => domain === null || statusPageHostnameRegex.test(domain), {
message: "Enter a valid domain name (e.g. status.example.com)",
}),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
@@ -35,7 +59,14 @@ export const statusPageSchema = z.object({
showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }),
customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }),
customCSS: z
.string()
.max(100000, "Custom CSS must be at most 100000 characters")
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional()
.register(statusPageStepRegistry, { step: 1 }),
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
-3
View File
@@ -941,10 +941,7 @@
"monitorId": "معرّف المراقب",
"runCount": "عدد التشغيلات",
"failCount": "عدد حالات الفشل",
"lastRunAt": "آخر تشغيل في",
"lockedAt": "مقفل في",
"lastFinishedAt": "آخر انتهاء في",
"lastRunTook": "مدة آخر تشغيل",
"lastFailedAt": "آخر فشل في",
"failReason": "سبب الفشل",
"state": "الحالة"
-3
View File
@@ -945,10 +945,7 @@
"monitorId": "ID del monitor",
"runCount": "Recompte d'execucions",
"failCount": "Recompte",
"lastRunAt": "Darrera execució",
"lockedAt": "Bloquejat el",
"lastFinishedAt": "Darrera finalització",
"lastRunTook": "Durada de la darrera execució",
"lastFailedAt": "Darrera fallada",
"failReason": "Motiu de la fallada",
"state": "Estat"
Loaded 100 of 573 files, more files were not shown because too many files have changed in this diff. Show more