Compare commits

...
Author SHA1 Message Date
Alexander Holliday c6461d2635 Revert "chore(i18n): close translation gaps across all 16 locales" 2026-04-27 10:14:01 -07:00
Gorkem Cetin 959e2ff7b8 Merge pull request #3585 from bluewave-labs/chore/locale-completeness
chore(i18n): close translation gaps across all 16 locales
2026-04-27 11:24:38 -04:00
gorkem-bwl 2f8765cf3e chore(i18n): close translation gaps across all 16 locales
- Seed the 29 keys that were missing in every non-English locale (Pushover/Twilio config, register setup notice, public status-page status-bar messages, "service is down")
- Fill core UI strings (sidebar menu, buttons, table headers, password rules, image upload, auth footer) where they were left as empty values in the original translations
- Bring Spanish to 100% net coverage with a hand pass over remaining empties and same-as-English entries

Coverage net of empty/missing keys:
- 6 locales now at 100% (de, es, ru, th, zh-CN, zh-TW)
- All locales above 94% (worst: ar/fi at 94.8%, was 85.1% / 87.9%)
- Zero missing keys in any locale (was 29 each)

Some keys are intentionally same-as-English (HTTP, gRPC, Pagespeed, Docker etc.). Long-form copy in createMonitor descriptions and a few infrastructure stat labels remain empty in the lower-coverage locales for native review.
2026-04-27 11:21:48 -04:00
gorkem-bwl 7342385729 fix(client): unblock the auth page build
Drop unused HeaderAuthControls/SPACING imports and switch the form-side wrapper from Stack to Box so the polymorphic component prop type-checks.
2026-04-26 02:29:23 -04:00
gorkem-bwl 671911dc1d feat(client): elevate auth pages with a two-column brand layout
- BaseAuthPage now renders a left form column and a right brand panel with eyebrow / tagline / description copy and a chess-board pattern over the brand green gradient
- Move the language selector and theme toggle to the page's top-right inside the form column so they read clearly in light and dark
- Every auth page (login, register, recovery, set new password) inherits the new layout
- Add brandPanel translations across all 16 locales; phrase the tagline as "your servers" instead of "stack" so it translates cleanly
2026-04-26 02:26:29 -04:00
gorkem-bwl 2ec55c6469 fix(client): replace lingering hardcoded colours with theme tokens
- Default status-page colour now uses the brand green instead of a royal-blue hex
- PageSpeed API-key alert pulls from theme.palette.warning + alpha for both modes instead of hand-rolled amber values
- Settings JSON preview block uses action.hover instead of the hand-rolled #1e1e1e/#f5f5f5 pair
- Drop the redundant #E0E0E0 thumb override on SwitchComponent so the theme MuiSwitch override actually wins
2026-04-26 01:59:09 -04:00
gorkem-bwl d678b3a304 fix(client): lighten the upload icon in ImageUpload
Match the rest of the icon system: 20px, 1.5 stroke, currentColor with 0.7 opacity instead of the lucide default heavy 24/2 stroke.
2026-04-26 01:56:28 -04:00
gorkem-bwl bf3bd50624 feat(client): unify card titles to the eyebrow style across uptime details
- StatBox titles now use the eyebrow variant (matches /uptime summary cards)
- BaseChart drops its leading icon block and renders the title via eyebrow so all chart cards share one header style
- Inside the average-response gauge, the qualitative label uses eyebrow with the status colour and the metric uses h1; the gauge track is a neutral disabled-action background so the active arc reads in light and dark
2026-04-26 01:56:22 -04:00
gorkem-bwl a4f69daae7 fix: surface notification test failures cleanly instead of as 500s
Server-side, return 200 with success: false and a friendly message when sendTestNotification reports failure (the underlying call already swallows the error); reserve real 500s for unexpected exceptions.

Client-side, teach usePost to honour the response body's success flag so success:false flips the toast to error without throwing through axios. Also tidy the toast container: drop icons, no wrapping, single-line ellipsis with a sensible max-width that hugs short messages.
2026-04-26 01:56:13 -04:00
gorkem-bwl 127ee5508a fix(client): truncate long notification destinations with a middle ellipsis
The destination column wrapped onto two lines for long webhook URLs. Cap the cell at 320px and use rtl + plaintext bidi so the start and end of the URL stay visible with an ellipsis in between; full address is in the title attribute.
2026-04-26 01:56:05 -04:00
gorkem-bwl d71fbf8c06 fix(client): bump dark-mode selected toggle background so it stands out
The 6% primary tint that read fine in light mode disappeared against dark backgrounds. Use a 22% tint in dark mode (30% on hover) and keep the lighter 6/10% in light mode.
2026-04-26 01:55:58 -04:00
gorkem-bwl aa3e72ec6d fix(client): make confirmation modals dismiss on ESC
Wire the dialog's onClose to onCancel for keyboard escape but ignore backdrop clicks so users can't lose work by clicking outside the modal.
2026-04-26 01:55:49 -04:00
gorkem-bwl 9d7f4929ad fix(client): use native language names in the settings dropdown too
Export the languageNames map and reuse it in the Settings page so EN/DE/TR turn into English/Deutsch/Türkçe alongside the auth-page selector.
2026-04-26 01:55:42 -04:00
gorkem-bwl 319f667028 feat(client): unify section headers on /logs and group titles with their tables
- Apply the eyebrow variant to "Runtime stats" and "Memory & CPU" so /logs sections share the same uppercase header style as Job queue
- Pair each section's heading + explainer with its table in a tighter sub-stack so the big gap only sits between sections, not under the explainer
- Restore the missing useTheme() call inside TableFailedJobs and TableLogs so the monospace token resolves
2026-04-26 01:55:33 -04:00
gorkem-bwl bcab1a0a4d fix(client): swap the sidebar logs icon from Database to ScrollText
ScrollText reads as logs/journal; Database read more like a storage admin tool.
2026-04-26 01:55:25 -04:00
gorkem-bwl 11ea7dcb3b feat(client): add a sidebar-specific accent colour for dark mode
The sidebar selected-icon colour was sharing primary.main, which read too dark in dark mode. Introduce palette.sidebar.accent (brand green in light mode, a lighter green in dark mode) and route NavItem through it so primary stays brand-green for buttons and the rest of the UI.
2026-04-26 01:55:17 -04:00
gorkem-bwl 1bff81c6bb fix(client): tighten the toast appearance
- Drop the per-type icon
- Lower min-height to 40px and tighten padding
- Thin both the active progress bar and its track to 2px and pin them to the same baseline so they sit on top of each other
2026-04-26 01:32:05 -04:00
gorkem-bwl 2016a208f2 fix(client): keep Chrome autofill background within the rounded input
The autofill inset shadow was bleeding past the rounded corners. Clip it via overflow: hidden on the outlined input root and bump the override rules to !important so they beat Chrome's user-agent styles.
2026-04-26 01:32:00 -04:00
gorkem-bwl dfc1b65e4b fix(client): restyle the register page setup notice
Drop the default MUI info-blue alert in favour of a neutral panel: theme-driven action.hover background, divider border, secondary text colour and no icon. Reads as a quiet aside instead of a status message.
2026-04-26 01:31:54 -04:00
gorkem-bwl f7629eb1be refactor(client): show native language names instead of country flags
Replace the flag + 2-letter code in the language selector with each language's native name (English, Deutsch, 日本語, ...) and drop the unused flag-icons dependency.
2026-04-26 01:31:47 -04:00
gorkem-bwl 72fc28c1fa fix(client): give auth pages a stable form width
The login/register form used percentage widths that collapsed to ~15% on wide screens, wrapping the "Forgot password" / "Reset password" links onto two lines. Switch to a fixed 400px max-width that just shrinks below it on small screens.
2026-04-26 01:31:41 -04:00
gorkem-bwl da70d07051 chore(client): document MUI default fallbacks in getResponseColor
Annotate the hex literals used when callers omit theme colours so the intent (defensive MUI default match) is obvious instead of looking like arbitrary values.
2026-04-26 01:22:06 -04:00
gorkem-bwl c6db7e6479 refactor(client): centralise monospace font through the theme
Add a fontFamilyMonospace token to theme.typography (ui-monospace stack with sensible fallbacks) and route the four ad-hoc fontFamily="monospace" call sites through it so the project has one source of truth.
2026-04-26 01:22:01 -04:00
gorkem-bwl 82ad40ac82 fix(client): replace hardcoded blue accents in tooltip and toast
- Tooltip backgrounds now pull from the theme's grey palette instead of a hand-rolled blue gradient (visible bug in dark mode)
- Toast progress bars use theme.palette.primary.main so they match the rest of the brand
2026-04-26 01:21:54 -04:00
gorkem-bwl 4fd9b2ecf5 feat(client): add eyebrow typography variant and lighter page titles
- Introduce a reusable eyebrow Typography variant in the theme (13px, 500 weight, uppercase, 0.08em tracking) for section headings
- Apply it to ConfigBox titles in /settings and to "Job queue" / "Failed jobs" in /logs
- Drop the page header weight from 600 to 400 with tighter tracking so titles read lighter and more refined
2026-04-26 01:21:47 -04:00
gorkem-bwl 2dccf73050 fix(client): green-tone the empty state illustration
The foreground card was painted in blue (#EEF4FE/#7DA8E8 etc.); recoloured to the brand green family so the illustration matches the rest of the UI.
2026-04-26 01:21:11 -04:00
gorkem-bwl d6532c60a6 fix(client): brand primary colour is the brand green, not blue
Renames the primary palette token from blueBlueWave to brandGreen and points it at #13715B in both light and dark themes, so primary buttons, sidebar selection, slider rings, switch tracks etc. follow the brand instead of the old blue.
2026-04-26 01:21:04 -04:00
gorkem-bwl 99be8651aa feat(client): unify brand mark — green king across sidebar, page header and favicon
- Replace the blue chess-piece SVG with a green king silhouette adapted from Cburnett's chess set (Wikimedia Commons, CC BY-SA 3.0); applies to the page-header logo and the browser favicon
- Drop the placeholder green "C" square in the sidebar in favour of the same king SVG
- Add a NOTICE file documenting the asset attribution and license
2026-04-26 01:07:16 -04:00
gorkem-bwl 8ca53bd765 fix(client): smaller, lighter labels on uptime status cards
Up/down/paused/initializing labels now use the same uppercase 11px treatment as the table headers, with looser tracking, instead of the heavier h2 variant.
2026-04-26 01:07:10 -04:00
gorkem-bwl 26e49f6161 fix(client): drop card chrome around the settings save button
The sticky save bar no longer renders a paper background, top border or full-width margin extension — it just sticks to the bottom on its own.
2026-04-26 00:42:16 -04:00
gorkem-bwl cca027b5eb fix(client): subtle tinted selection on toggle button group
Selected segment uses a 6% primary alpha rather than the previous green tint, and the per-button border override is gone so adjacent segments share one clean divider line.
2026-04-26 00:42:11 -04:00
gorkem-bwl 51f9c09132 feat(client): unify switch, radio and slider styling via theme overrides
- Switch: 36x20 contained pill with 6px right padding so labels keep their distance even inside Stacks
- Radio: theme-level override, 20px ring with a fixed-size inner dot — checked state no longer animates a scale change that pushed labels around
- Slider: white thumb with primary-colour ring, 4px track, soft hover/active glow
- Drop the custom RadioInput icon overrides that bypassed the theme and caused the size jiggle
2026-04-26 00:32:18 -04:00
gorkem-bwl 897204ead3 refactor(client): extract useClientPagination hook and clean up Select prop
- Replace duplicated page/rowsPerPage state in Team, Notifications and Status Pages tables with a shared useClientPagination hook
- Drop unused placeholderColor prop from Select; placeholder always uses secondary text
- Restore LAYOUT.MD constant for the account menu item gap
2026-04-26 00:22:14 -04:00
gorkem-bwl 25735df0c6 fix(client): use theme divider for table header line so it shows in dark mode 2026-04-26 00:18:45 -04:00
gorkem-bwl e3b6183e00 fix(client): soften empty state typography and copy
Drop the heavy 600-weight title in favour of regular-weight secondary text, and reword "Nothing here" to "Nothing to show yet".
2026-04-26 00:18:41 -04:00
gorkem-bwl b2741ad567 fix(client): increase internal padding on status summary cards 2026-04-26 00:18:37 -04:00
gorkem-bwl 7905e97a84 fix(client): match account menu icon style with sidebar nav icons 2026-04-26 00:13:35 -04:00
gorkem-bwl b7d719132d fix(client): use secondary text color for empty select placeholders
Disabled-text was too pale for filter dropdowns like Type/Status/State, where the placeholder doubles as the field label.
2026-04-26 00:13:32 -04:00
gorkem-bwl 210e03cea0 fix(client): refine table header divider and typography
- replace heavier per-cell header borders with one lighter row divider
- shrink header text to 11px with looser tracking for a more refined feel
- tighten failed-jobs labels: "Fail count" -> "Count", "Last failed at" -> "Last failed"
2026-04-26 00:13:27 -04:00
gorkem-bwl 995fc59299 feat(client): add pagination to team, notifications and status page tables 2026-04-26 00:13:23 -04:00
gorkem-bwl cbd8236cb4 chore(i18n): seed jobQueueExplainer and failedJobsExplainer for non-English locales 2026-04-26 00:13:19 -04:00
gorkem-bwl 095086cdfd chore(i18n): seed translations for new UI surfaces
Across en + 15 locales:
- pages.<X>.fallback.title / .description for every page that has an
  EmptyState (uptime, infrastructure, pageSpeed, notifications,
  maintenance, statusPages, incidents, logs, logs.queueFallback,
  checks, account.team).
- pages.<X>.header.title / .description for every page that uses the
  new PageHeader (same list, plus settings).
- pages.logs.diagnostics.sections.runtimeStats / .memoryCpu titles
  and descriptions for the diagnostics section explainers.
- components.pageHeader.links.support / docs / changelog tooltip
  labels.

en.json also gains pages.logs.jobQueueExplainer and
.failedJobsExplainer (English only — non-English locales fall back
to English until a translator sweep).
2026-04-25 23:28:51 -04:00
gorkem-bwl 59846a041f fix(client): dark-mode text-field polish
- MuiOutlinedInput hover override: was inheriting MUI's default
  white outline in dark mode (too bright). Now uses an alpha-23%
  white in dark and text.primary in light.
- TextInput: -webkit-autofill now repaints the input bg with
  background.paper (instead of Chrome's harsh blue/yellow autofill
  highlight), text in text.primary, with the standard 5000s-transition
  trick to suppress the brief flash. Works in both modes.
2026-04-25 23:28:32 -04:00
gorkem-bwl 7d91d9e28b fix(client): job queue table layout + theme-aware running-row tint
TableJobs:
- Cut from 11 to 7 columns, dropping active / runCount / failCount /
  lastFinishedAt (covered by Failed jobs table or by lastRun + lastRunTook).
- All cells get whiteSpace:nowrap + textOverflow:ellipsis + maxWidth so
  long timestamps and IDs no longer wrap and inflate row height; full
  value visible via title tooltip.
- Running-row tint: was theme.palette.success.light (too saturated in
  light, drowns white text in dark). Now #ECF7F2 in light, an 18%-alpha
  green in dark — readable in both modes.

TabQueue:
- One-line explainer above the Job queue heading, with the words
  "Highlighted rows" wrapped in the same green tint so the meaning
  is immediately associated.
- One-line explainer above Failed jobs heading too.
2026-04-25 23:27:50 -04:00
gorkem-bwl 752f59b09b feat(client): diagnostics page polish
- Stats and StatGauges use CSS grid (5 + 4 columns) so cards in each
  row are equal width; widths overridden against the legacy 25%-flex
  GradientBox so they fill their grid cell.
- DetailGauge gains a maxWidth prop (default 225) so consumers in
  wide grids can opt out of the cap.
- Section explainers above each row: "Runtime stats" + description,
  "Memory & CPU" + description (i18n driven, propagated to all 16
  locales via pages.logs.diagnostics.sections).
- Both grids render immediately with placeholder "—" values, so the
  initial paint shows the layout and values fill in as data loads
  (eliminates the layout shake when switching to the tab).
2026-04-25 23:27:35 -04:00
gorkem-bwl 262c4eee93 feat(client): PageHeader on every page; remove sidebar bottom menu
- New PageHeader component (variant D — grouped icon-pill toolbar):
  big title + muted description on the left, Support / Docs /
  Changelog as a 3-icon segmented pill on the right with tooltips.
  External links open in a new tab.
- BasePage / BasePageWithStates / MonitorBasePageWithStates accept
  a headerKey prop; when set, render PageHeader pulling
  pages.<headerKey>.header.title and .description from i18n.
  When unset, falls back to the existing breadcrumb (no behavior
  change for not-yet-migrated pages).
- All top-level pages (uptime, infrastructure, pageSpeed,
  notifications, maintenance, statusPages, incidents, logs, checks,
  account, settings) now pass their headerKey.
- Sidebar: removed the bottom menu (Support / Discussions / Docs /
  Changelog) since those links now live in the page header.
2026-04-25 23:27:22 -04:00
gorkem-bwl 031a6fdf3c feat(client): unified EmptyState across all pages and table empties
- New EmptyState + EmptyStateIllustration components: stacked-cards SVG
  with grid background, theme-aware light/dark palette.
- Variants: default (centered with title + description), compact
  (horizontal, in-section), fullscreen (vertically centers in viewport).
- Wired into Checks, Incidents, Logs (Logs+Queue tabs), Notifications,
  Maintenance, Status pages, and Team tab — each with i18n title +
  description and a fullscreen layout when truly empty.
- Table.EmptyView now wraps the EmptyState in a divider-bordered card
  so short messages no longer look orphaned.
- Incidents Option-A short-circuit: when summary.total === 0, replace
  the whole page chrome with EmptyState; otherwise keep filters + cards.
- Fallback.tsx (BaseFallback / EmptyFallback / EmptyMonitorFallback /
  ErrorFallback) refactored to delegate to EmptyState.
2026-04-25 23:26:34 -04:00
gorkem-bwl ff208710d7 feat(client): unified EmptyState component for empty/error placeholders
Replaces the cards-with-halo SkeletonCard fallback (which produced a
washed-out, hard-to-read placeholder) with a single canonical empty
state used everywhere in the app.

What's new:

- EmptyStateIllustration.tsx — custom inline SVG: two stacked monitor
  cards (front blue, back gray, both with hairline strokes and a soft
  fade toward the bottom), set against a faint grid of solid + dashed
  background lines. Uses only lightened palette tokens (#7DA8E8 blue,
  #B6C2CF gray, #CBD5E1 grid).
- EmptyState.tsx — the layout: illustration + title + description +
  optional CTA + optional secondary link + optional inline alert slot.
  Supports a compact horizontal variant for in-panel use.

Migrations:

- Fallback.tsx: BaseFallback / EmptyFallback / EmptyMonitorFallback /
  ErrorFallback now delegate to EmptyState. Public APIs unchanged so
  every existing consumer (BasePageWithStates, MonitorBasePageWithStates,
  Pages/Uptime, Pages/Notifications, Pages/Infrastructure, etc.) keeps
  working without edits.
- BasePage.tsx: PageSpeedKeyPriorityFallback rebuilt on top of EmptyState
  with the warning slot. Fixes the previous reports of "white cloudy
  area" + half-faded text — message and link now render in consistent
  dark text with green Settings link.

No prop changes for any page-level consumer. The illustration is a
single SVG with no external assets.
2026-04-25 21:19:52 -04:00
Gorkem Cetin 29f1041cc3 Merge pull request #3578 from ZeinebMegaadi/fix/docs-link
fix:replaced the broken link with the working one everywhere
2026-04-25 19:40:31 -04:00
ZeinebMegaadi a613bfb45a fix:replaced the broken link with the working one everywhere 2026-04-25 15:33:42 +01:00
Gorkem Cetin ed49fd41db Merge pull request #3575 from ayshaL/fix/notification-channel-typo
Fixed typo in notification
2026-04-24 19:17:58 -04:00
Aicha Lassoued 625f9b588e Fix typo in heapAllocation string 2026-04-24 22:45:45 +01:00
Aicha Lassoued 6fa73dce49 fixed typo 2026-04-24 22:24:00 +01:00
Alexander Holliday e8892af8ba Merge pull request #3573 from bluewave-labs/fix/swagger-monitors-teams-args
docs(openapi): document all query params on /monitors/team/with-checks
2026-04-24 10:43:34 -07:00
Alexander Holliday e413991e63 Merge pull request #3571 from bluewave-labs/fix/check-schema-errors-warning
fix(server): suppress mongoose reserved-key warning on Check.errors
2026-04-24 10:19:03 -07:00
Alexander Holliday e909ddd78b Merge pull request #3572 from bluewave-labs/fix/version-from-package-json
fix(client): source app version from package.json
2026-04-24 10:12:20 -07:00
Alexander Holliday 2ed5015aee Merge pull request #3570 from bluewave-labs/fix/vite-allowed-hosts
fix(client): allow configurable Vite allowed hosts
2026-04-24 09:58:27 -07:00
Alexander Holliday 6e89be2ac5 Merge pull request #3574 from bluewave-labs/fix/open-site-action-internal-url
fix(client): hide Open site action when URL is not browser-openable
2026-04-24 09:51:01 -07:00
gorkem-bwl 50fb4f47fd fix(client): hide Open site action when URL is not browser-openable
The Open site action in monitor row menus called `window.open(monitor.url)`
unconditionally, which produced unresolvable navigation for monitors
targeting internal services:
- Hardware monitors always point at a Capture agent (e.g.
  http://capture:59232/api/v1/metrics) that the user's browser can't
  reach.
- Port, ping, docker, game, grpc, and websocket monitors store hostnames
  or non-HTTP endpoints, which browsers can't load anyway.

This change hides the action where it can't possibly work:
- Removed entirely from the Infrastructure (hardware) monitors table.
- In the Uptime monitors table, only shown for http and pagespeed
  monitor types whose URL starts with http:// or https://.

A more comprehensive fix that adds a separate user-facing siteUrl
field on the Monitor schema is left for a follow-up.

Closes #3434
2026-04-23 23:38:07 -04:00
gorkem-bwl 22f1d035b7 docs(openapi): document all query params on /monitors/team/with-checks
The OpenAPI spec only listed `limit` for GET /monitors/team/with-checks,
but the route's Zod schema (getMonitorsWithChecksQueryValidation) also
accepts page, rowsPerPage, filter, field, order, type, and explain.
Users hitting the endpoint via Swagger UI would either get rejected
with "page is not allowed" (when guessing param names) or miss
pagination entirely.

Adds the missing parameters with constraints mirrored from the
validation schema. Also bumps `limit` max from 50 to 100 to match
the schema (was 100 in code, 50 in spec).

Closes #3354
2026-04-23 23:35:52 -04:00
gorkem-bwl f654310c4d fix(client): source app version from package.json
The client version shown on the Settings page was hardcoded as a
literal in vite.config.ts, while client/package.json sat at the
placeholder "0.0.0". Bumping the displayed version required editing
two unrelated places, and stale Docker client images would keep
showing the old number after upgrades.

This change makes client/package.json the single source of truth
and reads it at build time. Bumps client/package.json from 0.0.0
to 3.6.1 to preserve current behaviour.

Closes #3271
2026-04-23 23:33:41 -04:00
gorkem-bwl d44f7497a5 fix(server): suppress mongoose reserved-key warning on Check.errors
Mongoose 8 logs a reserved-key warning at boot because the Check schema
defines an `errors` field, which collides with Mongoose's internal
document `errors` accessor. The field is intentional and safe to keep,
so opt out of the warning via `suppressReservedKeysWarning: true`.

Closes #1539
2026-04-23 23:32:33 -04:00
gorkem-bwl dbccce445b fix(client): allow configurable Vite allowed hosts
Vite >=4.3 rejects requests with a Host header not in the allowlist,
which broke status-page submissions and dev-server access for users
serving Checkmate behind a reverse proxy after upgrading.

Adds VITE_ALLOWED_HOSTS env var (comma-separated list). When unset,
falls back to allowing all hosts so existing self-hosted deployments
keep working without changes.

Closes #3397
Closes #2951
2026-04-23 23:31:05 -04:00
Alexander Holliday 6ed0818bad Merge pull request #3566 from bluewave-labs/fix/build
fix: build
2026-04-23 11:07:39 -07:00
Alex Holliday f2e3640e28 add helper function to extract strings 2026-04-23 11:07:01 -07:00
Alex Holliday 8bfffd540d remove esbuild 2026-04-23 11:06:51 -07:00
Alex Holliday 559288fceb bump version 2026-04-23 10:14:05 -07:00
Alexander Holliday 8886566a8f Merge pull request #3564 from bluewave-labs/fix/resolve-incident-comment
fix comment type
2026-04-23 10:13:17 -07:00
Alex Holliday 28cb2aeacd fix comment type 2026-04-23 10:01:46 -07:00
Alex Holliday cbd82e29af bump version 2026-04-23 09:49:34 -07:00
Alexander Holliday 50876e57b9 Merge pull request #3520 from ZeinebMegaadi/docs/monitor-lifecycle-explained
docs: added monitor lifecycle explanation to README
2026-04-23 09:45:32 -07:00
Alexander Holliday b70916de20 Merge pull request #3561 from bluewave-labs/fix/line-endings
normalize line endings
2026-04-23 09:40:56 -07:00
Alex Holliday 3202a4276d normalize line endings 2026-04-23 09:30:43 -07:00
Gorkem Cetin 7a8caec159 Change demo link for Checkmate
Updated the demo link for Checkmate in the README.
2026-04-22 22:53:25 -04:00
Alexander Holliday 7e7f1bb6ca Merge pull request #3557 from bluewave-labs/fix/hide-register-tip-on-login
fix: hide register tip on login and redirect to setup when no super admin
2026-04-20 11:50:16 -07:00
Alex Holliday 5f12459bfd explicit import 2026-04-20 11:48:48 -07:00
Alex Holliday 27e866ba08 use lazy get 2026-04-20 11:47:43 -07:00
Alex Holliday f7a2b2af54 Merge branch 'develop' into fix/hide-register-tip-on-login 2026-04-20 11:28:25 -07:00
Alexander Holliday 3f35176651 Merge pull request #3558 from bluewave-labs/chore/remove-timescale-db
chore: remove timescale db
2026-04-20 11:28:07 -07:00
Alex Holliday 62c7e20726 purge pg types 2026-04-20 11:06:56 -07:00
Alex Holliday ff08645087 remove pool references, update docs 2026-04-20 11:04:43 -07:00
Alex Holliday 3e947ca2df remove bullmq, pg, bump dependencies 2026-04-20 11:03:13 -07:00
Alex Holliday cc32206547 purge timescaledb 2026-04-20 11:00:49 -07:00
Alexander Holliday 984a611d19 Merge pull request #3534 from egeoztass/feat/twilio-sms-notification
feat: add Twilio SMS notification channel
2026-04-20 10:54:12 -07:00
gorkem-bwl 39011e2e33 style: apply prettier formatting 2026-04-20 13:53:50 -04:00
gorkem-bwl 4998fe4c94 fix: hide register tip on login and redirect to setup when no super admin (#3556)
## Changes
- Removed the "Register here" link from the login page — it misled users into thinking public registration was open.
- Added redirect from /login to /register when no super admin exists (initial setup).
- Added an info alert on the register page clarifying the page is only for initial setup; additional users are invited from team settings. Hidden for the invite-token flow.
- Extracted shared useSuperAdminRedirect hook to dedupe identical fetch/redirect logic across Login and Register.

## Benefits
- Clearer UX: login page no longer suggests self-registration is available post-setup.
- Correct routing on fresh installs: users land on the registration page instead of a login page they can't use.
2026-04-20 13:50:36 -04:00
Gorkem Cetin 247308b636 Clarify demo access information in README
Separated demo credentials from the main demo description for clarity.
2026-04-20 12:38:02 -04:00
Alexander Holliday 33110cf6c8 Merge pull request #3545 from ZeinebMegaadi/fix/node-version-docs
docs: update Node.js version requirement to >=20 in contributing guide
2026-04-20 09:35:04 -07:00
Alexander Holliday 15c3fcb9b9 Merge pull request #3549 from SkyKingPX/develop
Add build_images.bat for Docker image builds
2026-04-20 09:34:24 -07:00
Alexander Holliday d5295f4f6c Merge pull request #3552 from Smolcup/docs/fix-code-of-conduct-repo-name-v2
docs: update repo name bluewave-uptime to checkmate in CODE_OF_CONDUCT
2026-04-20 09:33:30 -07:00
Alexander Holliday 886e04710f Merge pull request #3553 from Smolcup/docs/add-i18n-contribution-guide-v2
docs: update i18n contribution guide
2026-04-20 09:33:07 -07:00
Alexander Holliday d0e79ea99c Merge pull request #3555 from ZeinebMegaadi/fix/docs-link
fix: correct docs link URL (404 issue)
2026-04-20 09:30:34 -07:00
ZeinebMegaadi a0568a44a6 fix: correct docs link URL (404 issue) 2026-04-20 10:05:12 +01:00
Smolcup 4435a66255 docs: add i18n contribution guide 2026-04-19 21:21:04 +02:00
Smolcup dbc592b86b docs: update repo name bluewave-uptime to checkmate in CODE_OF_CONDUCT
Updated the Code of Conduct to reflect the new project name and ensure a welcoming environment for contributors.
2026-04-19 20:28:53 +02:00
SkyKing_PX 47a78edba8 Update Dockerfile paths for services in build_images.bat 2026-04-19 14:12:41 +02:00
SkyKing_PX f4fd836137 Fix paths in docker/coolify/build_images.sh 2026-04-19 14:12:00 +02:00
SkyKing_PX 6c6823cf76 Add build_images.bat for docker/coolify 2026-04-19 14:10:23 +02:00
SkyKing_PX 59187cc114 Add build_images.bat for docker/dist-mono 2026-04-19 14:07:32 +02:00
SkyKing_PX 0ad9ef15af Add build_images.bat for docker/dist 2026-04-19 14:06:14 +02:00
SkyKing_PX e23a185eaf Fix paths in docker/unified/build_image.sh 2026-04-19 14:04:37 +02:00
SkyKing_PX 36cbe4096d Add build_images.bat for docker/unified 2026-04-19 14:03:35 +02:00
SkyKing_PX ab692f8033 Add windows buildscript in docker/staging 2026-04-19 14:01:44 +02:00
SkyKing_PX 15542154f4 Fix paths 2026-04-19 14:00:33 +02:00
SkyKing_PX 7eb7fa1629 Add build_images.bat for Docker image production builds 2026-04-19 13:50:33 +02:00
ZeinebMegaadi 7906fce77f docs: update Node.js version requirement to >=20 in contributing guide 2026-04-17 21:17:40 +01:00
egeoztassandClaude Opus 4.6 c57614e8df fix: add accountSid and twilioPhoneNumber to notification repositories
Updated both MongoDB and TimescaleDB repositories to map the new
Twilio-specific fields (accountSid, twilioPhoneNumber) in create,
update, and toEntity methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:14:16 +03:00
egeoztassandClaude Opus 4.6 d5d6797f91 fix: use dedicated Twilio fields and add service switch case tests
- Added `accountSid` and `twilioPhoneNumber` fields to Notification
  type, MongoDB model, and frontend type instead of reusing
  `address` and `homeserverUrl`
- Updated provider, validation, form hook, and form UI to use new fields
- Added Pushover and Twilio to notificationsService routing tests
  (both handleNotifications and sendTestNotification)
- All 59 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 23:11:11 +03:00
Alexander Holliday 77545e9cf7 Merge pull request #3544 from bluewave-labs/fix/unified-docker-compose
fix/unified docker compose
2026-04-17 12:26:31 -07:00
Alex Holliday 29ea54419c add staging 2026-04-17 19:20:31 +00:00
Alexander Holliday 9042cb917a Merge pull request #3535 from Smolcup/docs/fix-discussions-link-readme-es
docs: fix discussions link in Spanish README
2026-04-17 12:12:38 -07:00
Alexander Holliday a65e1071a5 Merge pull request #3541 from Smolcup/docs/fix-typo-pullrequests
docs: fix typo 'big' to 'bug' in PULLREQUESTS.md
2026-04-17 11:28:32 -07:00
Alex Holliday 4493cca871 Merge branch 'fix/unified-docker-compose' of github.com:bluewave-labs/checkmate into fix/unified-docker-compose 2026-04-17 11:26:20 -07:00
Alex Holliday 999dfb13b9 update workflows 2026-04-17 11:25:53 -07:00
Alex Holliday 97f4008f4c remove explicit ports 2026-04-17 18:24:01 +00:00
Alex Holliday 6152144807 add staging, comment out for now 2026-04-17 11:18:03 -07:00
Alex Holliday b376fe9ab0 comment out staging 2026-04-17 11:07:25 -07:00
Alex Holliday aa3e097e16 initial commit 2026-04-17 10:54:39 -07:00
Alexander Holliday d77aeeb46c Merge pull request #3542 from bluewave-labs/fix/docker-compose
fix: update production docker compose/nginx conf
2026-04-17 10:37:58 -07:00
Alex Holliday 82b7ab83dd update production docker compose/nginx conf 2026-04-17 17:34:23 +00:00
Smolcup 88bc4e080d docs: fix typo 'big' to 'bug' in PULLREQUESTS.md 2026-04-17 19:27:57 +02:00
Alexander Holliday fa6aef1e86 Merge pull request #3518 from ZeinebMegaadi/fix/showing-down-for-down-monitors
Fix: added "down" display on down monitors instead of "Active for:..."
2026-04-17 10:17:34 -07:00
Gorkem Cetin 57ed0e1e7d Enhance README with PikaPods run button
Added a run button for PikaPods and improved project description.
2026-04-17 11:46:37 -04:00
ZeinebMegaadi 26077151ef fix: added maintenance state 2026-04-16 20:54:09 +01:00
Smolcup 55b05a2100 docs: fix discussions link in Spanish README 2026-04-16 21:43:03 +02:00
egeoztassandClaude Opus 4.6 9210259511 feat: add Twilio SMS notification channel
Adds Twilio SMS as a new notification channel, allowing users to
receive SMS alerts via the Twilio API.

Backend: provider using HTTP Basic Auth, type registration, validation,
service wiring. Uses existing fields: address (Account SID),
accessToken (Auth Token), homeserverUrl (From number), phone (To number).
Frontend: form UI with 4 fields, validation, i18n keys.
Tests: 18 provider tests + updated service test constructor.

Closes #3095

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:46:40 +03:00
Alexander Holliday e9a0914fad Merge pull request #3530 from egeoztass/feat/pushover-notification
feat: add Pushover notification channel
2026-04-16 11:21:43 -07:00
Alexander Holliday f392bee287 Merge pull request #3533 from bluewave-labs/fix/monitor-status-atomicity
fix: monitor status atomicity
2026-04-16 11:14:57 -07:00
egeoztassandClaude Opus 4.6 4281e87642 fix: add pushoverProvider to NotificationsService test constructor
The existing test was missing the new pushoverProvider argument,
causing settingsService to be passed in the wrong position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:14:47 +03:00
Alex Holliday d40b766c83 timescaleDB implementation 2026-04-16 11:12:06 -07:00
Alexander Holliday d139db7697 Merge pull request #3512 from SkyKingPX/feat/improved-status-bar-messages
Improve status bar messages on public status pages
2026-04-16 11:02:07 -07:00
Alex Holliday 7547d1db2e update tests 2026-04-16 10:59:43 -07:00
Alex Holliday 24d7ead1d1 use a projected window instead of read/write 2026-04-16 10:59:30 -07:00
SkyKingPX e60dbb9f74 Add .gitattributes file so LF endings are preserved 2026-04-16 19:57:21 +02:00
Alexander Holliday f12a96ce58 Merge pull request #3532 from mayssagl/fix/email-templates-missing-from-dist
fix: copy email templates to dist during build
2026-04-16 10:56:55 -07:00
SkyKingPX 5920b92869 Run npm format 2026-04-16 19:55:17 +02:00
Alex Holliday 8f55d966f9 rename methods, remove unused helpers 2026-04-16 10:50:30 -07:00
Alex Holliday c7ef412d7a atomic update 2026-04-16 10:41:00 -07:00
mayssagl 14f76ec10b fix: copy email templates to dist during build 2026-04-16 18:35:17 +01:00
ZeinebMegaadi d532d13371 fix(lifecycle updated) 2026-04-16 18:33:44 +01:00
ZeinebMegaadi 58532d1ff6 fix(monitors): handle initializing and breached states in stat box 2026-04-16 18:09:05 +01:00
Alexander Holliday b5fe314dde Merge pull request #3531 from mayssagl/fix-claude.md
docs: improve CLAUDE.md with architecture flow and repository pattern…
2026-04-16 10:01:33 -07:00
SkyKingPX 0d163e7100 Change Breached to exceeded thresholds 2026-04-16 18:56:06 +02:00
mayssagl 5dda0b51c3 docs: improve CLAUDE.md with architecture flow and repository pattern explanation 2026-04-16 17:42:11 +01:00
Alexander Holliday 273aa7d01a Merge pull request #3529 from SkyKingPX/feat/docker-images-script-windows
Add windows batch script for creating docker images automatically
2026-04-16 09:41:15 -07:00
egeoztassandClaude Opus 4.6 cc1b6df8f2 style: run prettier on notification create page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:55:30 +03:00
egeoztassandClaude Opus 4.6 97353bc7d2 feat: add Pushover notification channel
Adds Pushover as a new notification channel, allowing users to receive
push notifications on their devices via the Pushover API.

Backend: provider, type registration, validation, service wiring
Frontend: form UI with app token and user key fields, validation, i18n
Tests: full unit test suite (15 tests, 100% coverage on provider)

Closes #3445

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:25:24 +03:00
SkyKingPX 10a703d657 Update CONTRIBUTING.md 2026-04-16 13:49:58 +02:00
SkyKingPX b3077f6239 Merge branch 'feat/docker-images-script-windows' of https://github.com/SkyKingPX/Checkmate into feat/docker-images-script-windows 2026-04-16 13:46:33 +02:00
SkyKingPX 80ae5a1977 Add windows docker image build script 2026-04-16 13:46:16 +02:00
SkyKingPX 8579708f05 Add script 2026-04-16 13:41:30 +02:00
SkyKing_PX 675ec40836 Merge branch 'bluewave-labs:develop' into feat/improved-status-bar-messages 2026-04-16 13:40:10 +02:00
SkyKingPX 7b164e4df5 Make requested changes 2026-04-16 13:37:54 +02:00
Alexander Holliday 2be1b48995 Merge pull request #3527 from bluewave-labs/fix/demo-user
fix: demo user
2026-04-15 14:02:22 -07:00
Alex Holliday be3b7c2e86 add demo role to list of roles 2026-04-15 13:54:05 -07:00
Alex Holliday 69bec42a66 restrict editing user routes to regular users 2026-04-15 13:53:55 -07:00
Alexander Holliday 12b879ced5 Merge pull request #3526 from bluewave-labs/fix/notification-deletion
fix: remove notifications from monitors before deleting notifications
2026-04-15 13:31:42 -07:00
Alex Holliday 9e24cc655c remove notifications from monitors before deleting notifications 2026-04-15 13:21:14 -07:00
Alexander Holliday 95db80db5d Merge pull request #3525 from bluewave-labs/feat/initializaiton-tests
feat: initializaiton tests
2026-04-15 11:19:07 -07:00
Alexander Holliday ab7f2c42f5 Merge pull request #3524 from bluewave-labs/fix/streak-time
fix: tighten streak time coniditional
2026-04-15 11:09:33 -07:00
Alex Holliday be462811ac test initializing state 2026-04-15 11:08:40 -07:00
Alex Holliday 564b356115 tighten streak time coniditional 2026-04-15 11:06:30 -07:00
Alexander Holliday bcacd42162 Merge pull request #3523 from bluewave-labs/fix/initialization-state
fix: monitor init
2026-04-15 11:01:15 -07:00
Alex Holliday 6f7fba5ae5 fixes a bug where state was not correctly calcluated for monitors in initializing state 2026-04-15 10:54:43 -07:00
Alexander Holliday 61d753b119 Merge pull request #3522 from bluewave-labs/feat/additional-heartbeat-tests
feat: add additional heartbeat tests
2026-04-15 10:33:44 -07:00
ZeinebMegaadi b482e871a3 updated fix for same issue 2026-04-15 16:12:10 +01:00
ZeinebMegaadi bca602f4b0 docs: add monitor lifecycle explanation to README 2026-04-15 15:13:35 +01:00
ZeinebMegaadi f6222014b7 Fix: correct Active for display on down monitors 2026-04-15 14:51:12 +01:00
SkyKing_PX 360e8ad2a0 Merge branch 'bluewave-labs:develop' into feat/improved-status-bar-messages 2026-04-14 20:47:59 +02:00
Alex Holliday e8bcfdd21d add additional heartbeat tests 2026-04-14 11:31:09 -07:00
Alexander Holliday fd8dce8abe Merge pull request #3510 from bluewave-labs/feat/monitor-status-refactor
feat/monitor status refactor
2026-04-14 10:59:44 -07:00
Alex Holliday b9c14c76c9 add tests for new helper functions 2026-04-14 10:46:26 -07:00
Alex Holliday bf60c52692 simplify hardware changes 2026-04-14 10:41:55 -07:00
SkyKingPX a8339c0169 Improve status bar messages on public status pages 2026-04-14 19:32:05 +02:00
Alex Holliday d74f1bb6a8 add private helper methods to updateMonitorStats 2026-04-14 10:28:43 -07:00
Alexander Holliday 9bb7224fc4 Merge pull request #3505 from bluewave-labs/fix/notification-fix-down
fix: notification down not sent
2026-04-14 09:43:05 -07:00
Alex Holliday 403290db32 Merge branch 'develop' into fix/notification-fix-down 2026-04-14 09:41:16 -07:00
Alexander Holliday 98ae6b1105 Merge pull request #3508 from bluewave-labs/fix/monitor-status-tests
fix: additional status service tests
2026-04-14 09:40:58 -07:00
Alex Holliday 2014825618 additional tests 2026-04-14 09:40:14 -07:00
Alexander Holliday 4a61bc88d3 Merge pull request #3507 from bluewave-labs/fix/monitor-status-tests
fix: add test for monitor status
2026-04-14 09:37:27 -07:00
Alex Holliday fa4c0632ae add test for monitor status 2026-04-14 09:35:58 -07:00
mannilakash ece16158ef fix: notification down not sent 2026-04-14 12:07:51 -04:00
Alexander Holliday bd27721685 Merge pull request #3502 from bluewave-labs/feat/test-workflow
feat: add check-tests workflow
2026-04-13 13:11:23 -07:00
Alex Holliday 7c8549d092 fix messages 2026-04-13 13:08:33 -07:00
Alex Holliday 1e8c9d0187 stub out poeditor-sync 2026-04-13 13:07:21 -07:00
Alexander Holliday faa59ab694 Merge pull request #3501 from bluewave-labs/fix/notification-timeouts
fix: notification timeouts
2026-04-13 13:03:37 -07:00
Alex Holliday de4de0f5db add check-tests workflow 2026-04-13 13:03:22 -07:00
Alex Holliday 48c1e326c8 use relative paths for testing 2026-04-13 12:59:49 -07:00
Alex Holliday 22f54391d0 use abstract class 2026-04-13 12:55:20 -07:00
Alex Holliday 0bb3320010 add abstract class with default timeout and helper method 2026-04-13 12:54:51 -07:00
Alexander Holliday 7998bae87d Merge pull request #3500 from bluewave-labs/fix/invite-validation
fix: enforce correct role types
2026-04-13 12:37:33 -07:00
Alex Holliday ec929aef45 enforce correct role types 2026-04-13 12:26:14 -07:00
Alexander Holliday 77e72bc3bf Merge pull request #3499 from bluewave-labs/fix/update-running-stats
fix: remove in memory modification of monitor stats
2026-04-13 12:18:31 -07:00
Alex Holliday e1b00ce735 dedupe monitor stats, run migrations before index creation 2026-04-13 11:58:24 -07:00
Alex Holliday 0fcb38f7e6 Merge branch 'develop' into fix/update-running-stats 2026-04-13 11:48:18 -07:00
Alex Holliday b3930b31f2 remove in memory modification of monitor stats 2026-04-13 11:39:29 -07:00
Alexander Holliday 6222635eef Merge pull request #3498 from bluewave-labs/fix/stats-init
fix: initialize timeOfLastFailure
2026-04-13 11:01:59 -07:00
Alex Holliday dc7688bc13 initialize timeOfLastFailure 2026-04-13 10:58:58 -07:00
Gorkem Cetin c287cf16d1 Update pull_request_template.md 2026-04-13 13:38:48 -04:00
Alexander Holliday ff114b3ab2 Merge pull request #3494 from bluewave-labs/feat/integration-tests
feat: integration tests
2026-04-11 15:03:22 -07:00
Alex Holliday 233966b73d add heartbeat tests 2026-04-11 14:45:36 -07:00
Alex Holliday 45d4779b45 fix race condition in heartbeat job 2026-04-11 13:57:57 -07:00
Alexander Holliday d608b5ea24 Merge pull request #3492 from bluewave-labs/feat/service-tests
feat/service tests
2026-04-10 12:43:54 -07:00
Alex Holliday 1ad750e2ef fix ternary condiiton 2026-04-10 12:41:46 -07:00
Alex Holliday 4d1af5e2b4 fix TS errors, add missing tests 2026-04-10 12:33:08 -07:00
Alex Holliday b515d688f4 add geo check service tests 2026-04-10 11:43:02 -07:00
Alex Holliday ff92b307bb add diagnosticService tests 2026-04-10 11:39:26 -07:00
Alex Holliday e4ffe0e267 inviteSerice tests 2026-04-10 11:30:31 -07:00
Alex Holliday 2b121355c8 status page service tests 2026-04-10 11:25:54 -07:00
Alex Holliday e7db938baa import monitors bug fix 2026-04-10 11:15:28 -07:00
Alex Holliday 1b746d0f0a remvoe dead code from monitor service 2026-04-10 11:13:12 -07:00
Alex Holliday 6886fc75de monitorService tests 2026-04-10 11:07:48 -07:00
Alex Holliday 5c5f84a5b2 user service tests 2026-04-10 10:59:48 -07:00
Alex Holliday a12154523f settingsService tests 2026-04-10 10:50:37 -07:00
Alex Holliday 6c8adf8594 add tests for maintenancewindowsercie 2026-04-10 10:39:04 -07:00
Alex Holliday a0f73e4e8d add notificaiton provider tests 2026-04-09 11:05:21 -07:00
Alex Holliday 63efef65c7 add provider tests 2026-04-09 10:36:59 -07:00
Alex Holliday dca09daf35 add tests for globalPingService 2026-04-09 10:03:23 -07:00
Alex Holliday db833b441a add tests for emailService 2026-04-09 09:49:12 -07:00
Alex Holliday bb55f3b3eb add tests for notificaitonMessageBuilder 2026-04-09 09:41:17 -07:00
Alex Holliday 88e0660762 add tests for buffer service 2026-04-09 09:36:51 -07:00
Alex Holliday a8c172c77f add tests for network service 2026-04-09 09:27:38 -07:00
Alex Holliday b355da4aed Merge branch 'develop' into feat/server-testing 2026-04-09 09:15:55 -07:00
Alexander Holliday 77a4bf9c36 Merge pull request #3479 from ad2ien/fix/matrix-notif
Fix matrix notifications
2026-04-09 09:15:15 -07:00
Adrien Touzouli 6a0f4372c6 fix(notifications): fix matrix notifications
Respect Matrix spec, factorize api call and improve error log
2026-04-09 06:08:05 +02:00
191 changed files with 13392 additions and 6654 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
* text=auto
* text eol=lf
+7 -6
View File
@@ -1,27 +1,28 @@
**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)**
**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)**
## Describe your changes
Briefly describe the changes you made and their purpose.
Briefly describe the changes you made and their purpose.
## Write your issue number after "Fixes "
Fixes #123
Fixes #123
## Please ensure all items are checked off before requesting a review. "Checked off" means you need to add an "x" character between brackets so they turn into checkmarks.
- [ ] (Do not skip this or your PR will be closed) I deployed the application locally.
- [ ] (Do not skip this or your PR will be closed) I have performed a self-review and testing of my code.
- [ ] (Do not skip this or your PR will be closed) I have performed a self-reviewing and testing of my code.
- [ ] I have included the issue # in the PR.
- [ ] I have added i18n support to visible strings (instead of `<div>Add</div>`, use):
- [ ] I have added i18n support to visible strings (instead of `<div>Add</div>`, use):
```Javascript
const { t } = useTranslation();
<div>{t('add')}</div>
```
- [ ] I have **not** included any files that are not related to my pull request, including package-lock and package-json if dependencies have not changed
- [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application).
- [ ] I made sure font sizes, color choices etc are all referenced from the theme. I don't have any hardcoded dimensions.
- [ ] My PR is granular and targeted to one specific feature.
- [ ] I ran `npm run format` in server and client directories, which automatically formats your code.
- [ ] I took a screenshot or a video and attached to this PR if there is a UI change.
+23
View File
@@ -0,0 +1,23 @@
name: Test Check (Server)
on:
pull_request:
workflow_dispatch:
jobs:
test-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install server dependencies
working-directory: server
run: npm install
- name: Run server tests
working-directory: server
run: npm test
+19 -1
View File
@@ -1,4 +1,22 @@
# name: POEditor Translation Synchronization
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
+3 -2
View File
@@ -2,7 +2,7 @@ name: Demo deploy
on:
push:
branches: ["demo"]
branches: [ "demo" ]
workflow_dispatch:
jobs:
docker-build-and-push-client:
@@ -84,7 +84,8 @@ jobs:
script: |
cd checkmate
git pull
cd docker/prod
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
+5 -5
View File
@@ -2,7 +2,7 @@ name: Staging deploy
on:
push:
branches: ["develop"]
branches: [ "develop" ]
jobs:
docker-build-and-push-client:
@@ -78,13 +78,13 @@ jobs:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.STAGING_SERVER_HOST }}
username: ${{ secrets.STAGING_SERVER_USER }}
key: ${{ secrets.STAGING_SERVER_SSH_KEY }}
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/staging
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
+1 -3
View File
@@ -3,10 +3,8 @@
.VSCodeCounter
*.sh
mongo
timescaledb
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
docs/timescale
docs/frontend
+51 -1
View File
@@ -105,6 +105,56 @@ client/src/
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
- **i18n**: i18next + react-i18next (translations via PoEditor)
---
## Backend Architecture Patterns
### Repository Pattern & Separation of Concerns
The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access:
```
Request → Controller → Service → Repository → MongoDB (Mongoose)
```
- **Controllers** (`/controllers`) handle HTTP concerns only: parsing request params, calling the appropriate service, and returning a response via the `responseHandler` middleware. They contain no business logic.
- **Services** (`/service/business`) contain all business logic: deciding whether an incident should be created, whether a notification should fire, what state a monitor is in, etc.
- **Repositories** (`/repositories`) are the sole layer that talks to MongoDB through Mongoose. They expose clean, reusable query methods (e.g. `findByMonitorId`, `createCheck`) so that services never construct raw DB queries directly.
This separation makes each layer independently testable and keeps Mongoose-specific code out of business logic. When adding a new feature, the pattern to follow is: add a repository method for any new DB query, call it from a service, and expose it via a controller route.
### Monitoring Flow: From Check to Notification
Background monitoring runs on a scheduled queue, not on the HTTP request cycle. The high-level flow for uptime monitoring is:
```
Pulse (cron) → BullMQ Job → StatusService
├── performs HTTP/port/ping check
├── saves Check via CheckRepository
├── evaluates monitor state change
│ └── calls IncidentService (create / resolve incident)
└── calls NotificationService (email, Slack, Discord, webhook)
```
1. **Pulse** (cron scheduler) enqueues a job into a **BullMQ** queue for each active monitor at its configured interval.
2. A **BullMQ worker** picks up the job and calls `StatusService`, which performs the actual check (HTTP request, TCP port probe, ping, etc.).
3. The result is persisted as a `Check` document via the repository layer.
4. `StatusService` compares the new result against the monitor's previous state. If the monitor transitions from up → down (or down → up), it delegates to `IncidentService` to open or resolve an `Incident` document.
5. On a state change, `NotificationService` reads the monitor's configured `Notification` documents and dispatches alerts to all enabled channels (email, Discord, Slack, webhooks).
### Queue System (BullMQ + Redis)
Redis serves two roles: job queue storage for BullMQ and ephemeral caching. BullMQ manages concurrency, retries, and backpressure for monitoring jobs, ensuring checks are processed reliably even under load.
- Each monitor type (HTTP, port, ping, infrastructure) maps to its own queue worker so failures in one type don't block others.
- Job scheduling interval is driven by the `interval` field on the `Monitor` model.
- Failed jobs are retried with configurable backoff before being moved to a dead-letter state.
- Redis is also used to cache frequently read data (e.g. aggregated stats) to reduce MongoDB query pressure.
When working on anything related to check scheduling, incident lifecycle, or notifications, trace the flow starting from the relevant BullMQ worker rather than from the controller layer.
---
## Code Conventions
### Internationalization
@@ -142,4 +192,4 @@ Key Mongoose models in `/server/src/db/models/`:
- **StatusPage** - Public status pages
- **Notification** - Alert configuration (email, Discord, Slack, webhooks)
- **MaintenanceWindow** - Scheduled maintenance periods
- **AppSettings** - Global application settings
- **AppSettings** - Global application settings
+69 -69
View File
@@ -1,69 +1,69 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in the **bluewave-uptime** project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community, especially during Hacktoberfest, where we encourage new contributors to join and feel supported.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, especially during Hacktoberfest to ensure a positive experience for all contributors.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when an individual is officially representing the project or its community in public spaces. Examples of representing our project include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@bluewavelabs.ca. All complaints will be reviewed and investigated promptly and fairly.
All project team members are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
---
By following this Code of Conduct, we can ensure a welcoming and inclusive environment, encouraging new contributors to engage with the project and the community. Thank you for helping make **bluewave-uptime** a positive space for all!
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in the **checkmate** project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community, especially during Hacktoberfest, where we encourage new contributors to join and feel supported.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, especially during Hacktoberfest to ensure a positive experience for all contributors.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when an individual is officially representing the project or its community in public spaces. Examples of representing our project include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@bluewavelabs.ca. All complaints will be reviewed and investigated promptly and fairly.
All project team members are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
---
By following this Code of Conduct, we can ensure a welcoming and inclusive environment, encouraging new contributors to engage with the project and the community. Thank you for helping make **checkmate** a positive space for all!
+9 -3
View File
@@ -16,7 +16,7 @@ PS: **We work closely with contributors on our [Discord channel](https://discord
Before you dive in, make sure you have these installed:
```bash
# Check Node.js (v16+ required)
# Check Node.js (v20+ required)
node --version
# Check npm
@@ -96,10 +96,16 @@ cd docker/dev
Build the Docker images:
Linux:
```bash
./build_images.sh
```
Windows:
```bash
./build_images.bat
```
Run MongoDB container:
```bash
@@ -234,7 +240,7 @@ docker rm uptime_database_mongo
**Need more help?**
- Check the [full documentation](https://docs.checkmate.so)
- Check the [full documentation](https://checkmate.so/docs)
- Ask on [Discord](https://discord.com/invite/NAb6H3UTjK)
### Start contributing code?
@@ -310,7 +316,7 @@ If one or more of these are missing, we may ask you to update your pull request
- `master` is used for stable releases.
- Use descriptive branch names, like `fix/login-error` or `feat/add-alerts`.
- Make sure that you are using the latest version.
- Make sure you run the code locally. The Checkmate [documentation](https://docs.checkmate.so) covers it.
- Make sure you run the code locally. The Checkmate [documentation](https://checkmate.so/docs) covers it.
- Find out if the functionality is already covered, maybe by an individual configuration.
- Perform a [search](/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
+1 -1
View File
@@ -33,4 +33,4 @@ Ensure you execute `npm run format` before submitting your pull requests in both
### Keep PRs focused
It may be tempting to address a bug you suddenly remembered or make some tiny adjustments in some component that bothers you, but don’t! Keep all commits in your pull request fully focused on the specific feature you are working on. Open up another PR if you want to fix a big or work on another feature.
It may be tempting to address a bug you suddenly remembered or make some tiny adjustments in some component that bothers you, but don’t! Keep all commits in your pull request fully focused on the specific feature you are working on. Open up another PR if you want to fix a bug or work on another feature.
+4 -4
View File
@@ -49,11 +49,11 @@ Puedes ver la última versión de [Checkmate](https://checkmate-demo.bluewavelab
## Guía del usuario
Las instrucciones de uso se pueden encontrar [aquí](https://docs.checkmate.so/checkmate-2.1). Todavía está en desarrollo y parte de la información puede estar desactualizada ya que continuamente añadimos funciones cada semana. ¡Ten por seguro que estamos haciendo lo mejor posible! :)
Las instrucciones de uso se pueden encontrar [aquí](https://checkmate.so/docs). Todavía está en desarrollo y parte de la información puede estar desactualizada ya que continuamente añadimos funciones cada semana. ¡Ten por seguro que estamos haciendo lo mejor posible! :)
## Instalación
Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://checkmate.so/docs/getting-started/installation).
Alternativamente, también puedes usar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) o [Pikapods](https://www.pikapods.com/) para desplegar rápidamente una instancia de Checkmate. Si deseas monitorear tu infraestructura de servidores, necesitarás el agente [Capture](https://github.com/bluewave-labs/capture). El repositorio de Capture también contiene las instrucciones de instalación.
@@ -76,7 +76,7 @@ También puedes ver el consumo de memoria de MongoDB y Redis en el mismo servido
Si tienes alguna pregunta, sugerencia o comentario, tienes varias opciones:
- [Canal de Discord](https://discord.gg/NAb6H3UTjK)
- [Discusiones en GitHub](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Discusiones en GitHub](https://github.com/bluewave-labs/checkmate/discussions)
- [Grupo en Reddit](https://www.reddit.com/r/CheckmateMonitoring/)
¡No dudes en hacer preguntas o compartir tus ideas, nos encantaría saber de ti!
@@ -147,7 +147,7 @@ Cómo contribuir:
0. Dale una estrella al repositorio :)
1. Revisa la [guía para contribuidores](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Se anima a los nuevos a revisar las etiquetas `good-first-issue`.
2. Consulta la [estructura del proyecto](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) y la [visión general](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
2. Consulta la [estructura del proyecto](https://deepwiki.com/bluewave-labs/Checkmate#system-architecture) y la [visión general](https://deepwiki.com/bluewave-labs/Checkmate#overview).
3. Lee una estructura detallada de [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) si deseas profundizar en la arquitectura.
4. Abre un issue si crees que has encontrado un error.
5. Revisa los issues con la etiqueta `good-first-issue` si eres nuevo.
+14 -1
View File
@@ -14,6 +14,8 @@
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
@@ -41,7 +43,9 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
## Demo
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
You can see the latest build of [Checkmate](https://demo.checkmate.so/) in action.
The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## User's guide
@@ -110,6 +114,15 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
## Monitor Lifecycle
1. A monitor executes a check (HTTP / ping / port / hardware via Capture agent)
2. The result is stored (success/failure + response time)
3. Recent check results are evaluated against the monitor's configured status change threshold
4. If the monitor's status change threshold is met and the current status is not equal to the previous status, the monitor's state changes (e.g. `initializing`, `up`, `down`, `breached`)
5. Upon a state change: an incident is either created or resolved, depending on the monitor's current status
6. Notifications are triggered based on configuration
## Screenshots
<p>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"version": "3.6.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -25,10 +25,7 @@ export const MonitorStatBoxes = ({
}
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure =
timeOfLastFailure > 0
? Date.now() - timeOfLastFailure
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
@@ -42,6 +39,12 @@ export const MonitorStatBoxes = ({
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
monitor?.status === "maintenance" ||
monitor?.status === "initializing" ||
monitor?.status === "breached";
const palette = getStatusPalette(monitor?.status);
return (
@@ -51,8 +54,12 @@ export const MonitorStatBoxes = ({
>
<StatBox
palette={palette}
title={t("pages.common.monitors.statBoxes.activeFor")}
subtitle={streakTime}
title={
isActive
? t("pages.common.monitors.statBoxes.activeFor")
: t("pages.common.monitors.statBoxes.serviceIsDown")
}
subtitle={isActive ? streakTime : ""}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastCheck")}
+1 -1
View File
@@ -20,7 +20,7 @@ import { setCollapsed } from "@/Features/UI/uiSlice";
const URL_MAP: Record<string, string> = {
support: "https://discord.com/invite/NAb6H3UTjK",
discussions: "https://github.com/bluewave-labs/checkmate/discussions",
docs: "https://bluewavelabs.gitbook.io/checkmate",
docs: "https://checkmate.so/docs",
changelog: "https://github.com/bluewave-labs/checkmate/releases",
};
+18
View File
@@ -60,6 +60,24 @@ function buildDefaults(data: Notification | null): NotificationFormData {
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
@@ -43,6 +43,7 @@ export const AddTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => {
@@ -48,6 +48,7 @@ export const InviteTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => {
+15 -7
View File
@@ -1,6 +1,7 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -9,7 +10,7 @@ import type { LoginFormData } from "@/Validation/login";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { usePost } from "@/Hooks/UseApi";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
const LoginPage = () => {
const { t } = useTranslation();
@@ -17,6 +18,17 @@ const LoginPage = () => {
const navigate = useNavigate();
const { post, loading } = usePost();
const [isCheckingAdmin, setIsCheckingAdmin] = useState(true);
const { get } = useLazyGet<boolean>();
useEffect(() => {
get("/auth/users/superadmin").then((res) => {
if (res?.data === false) navigate("/register", { replace: true });
else setIsCheckingAdmin(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm<LoginFormData>({
@@ -24,6 +36,8 @@ const LoginPage = () => {
defaultValues: defaults,
});
if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => {
if (loading) return;
@@ -82,12 +96,6 @@ const LoginPage = () => {
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.register.text")}
linkText={t("pages.auth.login.links.register.linkText")}
href="/register"
/>
</BaseAuthPage>
);
};
+15 -11
View File
@@ -1,16 +1,17 @@
import { BaseAuthPage } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
import { useTranslation } from "react-i18next";
import { usePost, useGet } from "@/Hooks/UseApi";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">;
@@ -32,21 +33,23 @@ const RegisterPage = () => {
const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>();
const hasVerified = useRef(false);
const { data: superAdminExists, isLoading: isCheckingAdmin } = useGet<boolean>(
token ? null : "/auth/users/superadmin"
);
const [isCheckingAdmin, setIsCheckingAdmin] = useState(!token);
const { get } = useLazyGet<boolean>();
useEffect(() => {
if (token) return;
get("/auth/users/superadmin").then((res) => {
if (res?.data === true) navigate("/login", { replace: true });
else setIsCheckingAdmin(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
useEffect(() => {
if (superAdminExists === true) {
navigate("/login", { replace: true });
}
}, [superAdminExists, navigate]);
useEffect(() => {
if (!token || hasVerified.current) return;
hasVerified.current = true;
@@ -92,6 +95,7 @@ const RegisterPage = () => {
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && <Alert severity="info">{t("pages.auth.register.setupNotice")}</Alert>}
<Controller
name="firstName"
control={control}
@@ -80,14 +80,6 @@ export const InfraMonitorsTable = ({
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.openSite"),
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
{
id: 2,
label: t("pages.common.monitors.actions.details"),
+155 -24
View File
@@ -147,30 +147,33 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && watchedType !== "telegram" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
@@ -214,6 +217,134 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
@@ -1,35 +1,115 @@
import { AlertTriangle, CircleCheck } from "lucide-react";
import {
AlertTriangle,
CircleCheck,
CircleX,
Loader,
PauseCircle,
ShieldAlert,
Wrench,
} from "lucide-react";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import type { Theme } from "@mui/material";
import { useTheme } from "@mui/material";
import type { Monitor } from "@/Types/Monitor";
import type { Theme } from "@mui/material";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
interface StatusDisplay {
icon: JSX.Element;
msg?: string;
color?: string;
}
const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
const monitorsStatus: Record<string, any> = {
const monitorsStatus: StatusDisplay = {
icon: <AlertTriangle size={24} />,
};
if (monitors.every((monitor) => monitor.status === "up")) {
// Handle empty monitors array
if (monitors.length === 0) {
monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <CircleX size={24} />;
return monitorsStatus;
}
const allOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => statuses.includes(m.status));
const someOf = (...statuses: MonitorStatus[]) =>
monitors.some((m) => statuses.includes(m.status));
const noneOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => !statuses.includes(m.status));
// All monitors in a single state
if (allOf("up")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
monitorsStatus.color = theme.palette.success.main;
monitorsStatus.icon = <CircleCheck size={24} />;
return monitorsStatus;
} else if (monitors.every((monitor) => monitor.status === "down")) {
} else if (allOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (allOf("maintenance")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (allOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main;
return monitorsStatus;
} else if (monitors.some((monitor) => monitor.status === "down")) {
monitorsStatus.icon = <CircleX size={24} />;
} else if (allOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
} else if (allOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Loader size={24} />;
// Breached takes highest priority in mixed states
} else if (someOf("breached") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (someOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
// Maintenance combinations
} else if (someOf("maintenance") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (someOf("maintenance") && noneOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
// Degraded (some down, no maintenance/breached)
} else if (someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
monitorsStatus.color = theme.palette.warning.main;
return monitorsStatus;
monitorsStatus.icon = <AlertTriangle size={24} />;
// Some Paused
} else if (someOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
// Initializing
} else if (someOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
monitorsStatus.color = theme.palette.info.main;
monitorsStatus.icon = <Loader size={24} />;
} else {
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
monitorsStatus.color = theme.palette.warning.main;
return monitorsStatus;
}
return monitorsStatus;
};
interface StatusBarProps {
@@ -71,16 +71,24 @@ export const MonitorTable = ({
refetch();
};
const isBrowserOpenable = (monitor: Monitor): boolean => {
if (monitor.type !== "http" && monitor.type !== "pagespeed") return false;
return /^https?:\/\//i.test(monitor.url ?? "");
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
const actions: ActionMenuItem[] = [];
if (isBrowserOpenable(monitor)) {
actions.push({
id: 1,
label: t("pages.common.monitors.actions.openSite"),
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
});
}
actions.push(
{
id: 2,
label: t("pages.common.monitors.actions.details"),
@@ -130,8 +138,9 @@ export const MonitorTable = ({
),
action: () => setSelectedMonitor(monitor),
closeMenu: true,
},
];
}
);
return actions;
};
const getHeaders = (chartType: string) => {
+4
View File
@@ -7,6 +7,8 @@ export const NotificationChannels = [
"matrix",
"teams",
"telegram",
"pushover",
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -21,6 +23,8 @@ export interface Notification {
homeserverUrl?: string;
roomId?: string;
accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
createdAt: string;
updatedAt: string;
}
+16
View File
@@ -56,6 +56,20 @@ const telegramSchema = baseSchema.extend({
accessToken: z.string().min(1, "Bot token is required"),
});
const pushoverSchema = baseSchema.extend({
type: z.literal("pushover"),
address: z.string().min(1, "User key is required"),
accessToken: z.string().min(1, "App token is required"),
});
const twilioSchema = baseSchema.extend({
type: z.literal("twilio"),
accountSid: z.string().min(1, "Account SID is required"),
accessToken: z.string().min(1, "Auth token is required"),
phone: z.string().min(1, "Recipient phone number is required"),
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -65,6 +79,8 @@ export const notificationSchema = z.discriminatedUnion("type", [
matrixSchema,
teamsSchema,
telegramSchema,
pushoverSchema,
twilioSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+37 -4
View File
@@ -343,6 +343,7 @@
"register": {
"title": "Welcome back to Checkmate!",
"subtitle": "Sign up to get started",
"setupNotice": "This page is only available during initial setup. Once a super admin is registered, additional users can be invited from team settings.",
"submit": "Register"
},
"forgotPassword": {
@@ -397,7 +398,8 @@
"activeFor": "Active for",
"certificateExpiry": "Certificate expiry",
"lastCheck": "Last check",
"lastResponseTime": "Last response time"
"lastResponseTime": "Last response time",
"serviceIsDown": "Service is down"
},
"status": {
"down": "down",
@@ -810,7 +812,7 @@
"osMemoryLimit": "OS memory limit"
},
"gauges": {
"heapAllocation": "Heap allocaton",
"heapAllocation": "Heap allocation",
"heapUsage": "Heap usage",
"heapUtilization": "Heap utilization",
"instantCpuUsage": "Instant CPU usage",
@@ -909,7 +911,7 @@
"Let engineers know when incidents happen",
"Keep administrators informed of system changes"
],
"title": "Notification channles are used to:"
"title": "Notification channels are used to:"
},
"form": {
"accessToken": {
@@ -959,6 +961,26 @@
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
},
"pushover": {
"title": "Pushover configuration",
"description": "Configure Pushover to receive push notifications on your devices.",
"optionAppToken": "Application API token",
"placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi",
"optionUserKey": "User key",
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
},
"twilio": {
"title": "Twilio SMS configuration",
"description": "Configure Twilio to send SMS notifications to a phone number.",
"optionAccountSid": "Account SID",
"placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"optionAuthToken": "Auth token",
"placeholderAuthToken": "your_auth_token",
"optionFromNumber": "From number (Twilio)",
"placeholderFromNumber": "+15551234567",
"optionToNumber": "To number (recipient)",
"placeholderToNumber": "+15559876543"
}
},
"table": {
@@ -1208,9 +1230,20 @@
}
},
"statusBar": {
"allDown": "All systems are down",
"allUp": "All systems operational",
"allBreached": "All systems have exceeded thresholds",
"allMaintenance": "All systems are under maintenance",
"allDown": "All systems are down",
"allPaused": "All systems are paused",
"allInitializing": "All systems are initializing",
"breachedAndDown": "Some systems have exceeded thresholds and are down",
"breached": "Some systems have exceeded thresholds",
"degraded": "Some systems are experiencing issues",
"maintenanceAndDown": "Some systems are down during maintenance",
"maintenance": "Some systems are under maintenance",
"partiallyPaused": "Some systems are paused",
"initializing": "Some systems are initializing",
"noMonitors": "No monitors have been configured",
"unknown": "Unable to determine system status"
},
"table": {
+12 -1
View File
@@ -3,17 +3,28 @@ import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import path from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig(({}) => {
let version = "3.5.1";
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8"));
const version = pkg.version;
const allowedHosts = process.env.VITE_ALLOWED_HOSTS
? process.env.VITE_ALLOWED_HOSTS.split(",").map((h) => h.trim())
: true;
return {
base: "/",
plugins: [svgr(), react()],
server: {
host: true,
allowedHosts,
},
preview: {
host: true,
allowedHosts,
},
resolve: {
alias: {
+36
View File
@@ -0,0 +1,36 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=bluewaveuptime/uptime_client
set dockerfiles[0]=.\docker\coolify\client.Dockerfile
set services[1]=bluewaveuptime/uptime_database_mongo
set dockerfiles[1]=.\docker\coolify\mongoDB.Dockerfile
set services[2]=bluewaveuptime/uptime_redis
set dockerfiles[2]=.\docker\coolify\redis.Dockerfile
set services[3]=bluewaveuptime/uptime_server
set dockerfiles[3]=.\docker\coolify\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2,3) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+5 -5
View File
@@ -1,14 +1,14 @@
#!/bin/bash
# Change directory to root Server directory for correct Docker Context
cd "$(dirname "$0")"
cd ../../../
cd ../../
# Define an array of services and their Dockerfiles
declare -A services=(
["bluewaveuptime/uptime_client"]="./server/docker/dist/client.Dockerfile"
["bluewaveuptime/uptime_database_mongo"]="./server/docker/dist/mongoDB.Dockerfile"
["bluewaveuptime/uptime_redis"]="./server/docker/dist/redis.Dockerfile"
["bluewaveuptime/uptime_server"]="./server/docker/dist/server.Dockerfile"
["bluewaveuptime/uptime_client"]="./docker/coolify/client.Dockerfile"
["bluewaveuptime/uptime_database_mongo"]="./docker/coolify/mongoDB.Dockerfile"
["bluewaveuptime/uptime_redis"]="./docker/coolify/redis.Dockerfile"
["bluewaveuptime/uptime_server"]="./docker/coolify/server.Dockerfile"
)
# Loop through each service and build the corresponding image
+33
View File
@@ -0,0 +1,33 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=uptime_client
set dockerfiles[0]=.\docker\dev\client.Dockerfile
set services[1]=uptime_mongo
set dockerfiles[1]=.\docker\dev\mongoDB.Dockerfile
set services[2]=uptime_server
set dockerfiles[2]=.\docker\dev\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+6 -7
View File
@@ -1,17 +1,16 @@
# ---------------------
# Frontend build stage
# ---------------------
FROM node:20-slim AS frontend-build
# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is
# pure JS so it's arch-independent, and this avoids the esbuild host/native
# binary version mismatch under emulation.
FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build
WORKDIR /app/client
COPY client/package.json ./
COPY client/package*.json ./
RUN npm install
RUN npm install esbuild@0.25.5 --build-from-source
RUN npm ci
COPY client ./
+30
View File
@@ -0,0 +1,30 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=uptime_database_mongo
set dockerfiles[0]=.\docker\dist-mono\mongoDB.Dockerfile
set services[1]=uptime_server
set dockerfiles[1]=.\docker\dist-mono\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+2 -2
View File
@@ -19,10 +19,10 @@ WORKDIR /app/server
COPY server ./
RUN npm install
RUN npm run build
RUN cp -r src/templates dist/templates
COPY --from=frontend-build /app/client/dist ./public
@@ -30,4 +30,4 @@ RUN chmod +x ./scripts/inject-vars.sh
EXPOSE 52345
CMD ./scripts/inject-vars.sh && node ./dist/index.js
CMD ./scripts/inject-vars.sh && node ./dist/index.js
+33
View File
@@ -0,0 +1,33 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=bluewaveuptime/uptime_client
set dockerfiles[0]=.\docker\dist\client.Dockerfile
set services[1]=bluewaveuptime/uptime_database_mongo
set dockerfiles[1]=.\docker\dist\mongoDB.Dockerfile
set services[2]=bluewaveuptime/uptime_server
set dockerfiles[2]=.\docker\dist\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+33
View File
@@ -0,0 +1,33 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=uptime_client
set dockerfiles[0]=.\docker\prod\client.Dockerfile
set services[1]=uptime_database_mongo
set dockerfiles[1]=.\docker\prod\mongoDB.Dockerfile
set services[2]=uptime_server
set dockerfiles[2]=.\docker\prod\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+3 -12
View File
@@ -3,8 +3,8 @@ services:
image: ghcr.io/bluewave-labs/checkmate:frontend-demo
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1"
UPTIME_APP_CLIENT_HOST: "https://checkmate-demo.bluewavelabs.ca"
UPTIME_APP_API_BASE_URL: "https://demo.checkmate.so/api/v1"
UPTIME_APP_CLIENT_HOST: "https://demo.checkmate.so"
ports:
- "80:80"
- "443:443"
@@ -12,16 +12,7 @@ services:
- server
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d/:ro
- ./certbot/www:/var/www/certbot/:ro
- ./certbot/conf/:/etc/nginx/ssl/:ro
certbot:
image: certbot/certbot:latest
restart: always
volumes:
- ./certbot/www/:/var/www/certbot/:rw
- ./certbot/conf/:/etc/letsencrypt/:rw
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done;'"
- /etc/letsencrypt:/etc/nginx/ssl:ro
server:
image: ghcr.io/bluewave-labs/checkmate:backend-demo
+2 -2
View File
@@ -40,8 +40,8 @@ server {
server_name checkmate-demo.bluewavelabs.ca;
ssl_certificate /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/privkey.pem;
ssl_certificate /etc/nginx/ssl/live/demo.checkmate.so/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/demo.checkmate.so/privkey.pem;
location / {
root /usr/share/nginx/html;
+2
View File
@@ -17,6 +17,8 @@ COPY ./server ./
RUN npm run build
RUN cp -r src/templates dist/templates
EXPOSE 52345
CMD ["node", "dist/index.js"]
+33
View File
@@ -0,0 +1,33 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=uptime_client
set dockerfiles[0]=.\docker\staging\client.Dockerfile
set services[1]=uptime_database_mongo
set dockerfiles[1]=.\docker\staging\mongoDB.Dockerfile
set services[2]=uptime_server
set dockerfiles[2]=.\docker\staging\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+2
View File
@@ -17,6 +17,8 @@ COPY ./server ./
RUN npm run build
RUN cp -r src/templates dist/templates
EXPOSE 52345
CMD ["node", "dist/index.js"]
+33
View File
@@ -0,0 +1,33 @@
@echo off
setlocal enabledelayedexpansion
REM Change directory to root Server directory for correct Docker Context
cd /d "%~dp0"
cd ../..
REM Define service names and their corresponding Dockerfiles
set services[0]=uptime_client
set dockerfiles[0]=.\docker\unified\client.Dockerfile
set services[1]=uptime_database_mongo
set dockerfiles[1]=.\docker\unified\mongoDB.Dockerfile
set services[2]=uptime_server
set dockerfiles[2]=.\docker\unified\server.Dockerfile
REM Loop through each service and build the corresponding image
for /L %%i in (0,1,2) do (
set service=!services[%%i]!
set dockerfile=!dockerfiles[%%i]!
docker build -f "!dockerfile!" -t "!service!" .
REM Check if the build succeeded
if errorlevel 1 (
echo Error building !service! image. Exiting...
exit /b 1
)
)
echo All images built successfully
exit /b 0
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Change directory to root directory for correct Docker Context
cd "$(dirname "$0")"
cd ../../
# Define an array of services and their Dockerfiles
declare -A services=(
["uptime_client"]="./docker/unified/client.Dockerfile"
["uptime_database_mongo"]="./docker/unified/mongoDB.Dockerfile"
["uptime_server"]="./docker/unified/server.Dockerfile"
)
# Loop through each service and build the corresponding image
for service in "${!services[@]}"; do
docker build -f "${services[$service]}" -t "$service" .
# Check if the build succeeded
if [ $? -ne 0 ]; then
echo "Error building $service image. Exiting..."
exit 1
fi
done
echo "All images built successfully"
+19
View File
@@ -0,0 +1,19 @@
version: "3"
services:
webserver:
image: nginx:latest
ports:
- 80:80
- 443:443
restart: always
volumes:
- ./nginx/conf.d/:/etc/nginx/conf.d/:ro
- ./certbot/www/:/var/www/certbot/:ro
certbot:
image: certbot/certbot:latest
volumes:
- ./certbot/www/:/var/www/certbot/:rw
- ./certbot/conf/:/etc/letsencrypt/:rw
depends_on:
- webserver
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-slim AS build
ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /app
COPY ./client/package*.json ./
RUN npm install
COPY ./client .
RUN npm run build
FROM nginx:1.27.1-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/env.sh /docker-entrypoint.d/env.sh
RUN chmod +x /docker-entrypoint.d/env.sh
CMD ["nginx", "-g", "daemon off;"]
+114
View File
@@ -0,0 +1,114 @@
# sudo certbot certonly --standalone --http-01-port 8080
# cerbot needs to run on 8080 since port 80 is used by the gateway
services:
# Gateway (Single Entry Point)
gateway:
image: nginx:1.27.1-alpine
container_name: checkmate-gateway
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/nginx/ssl:ro
- ./gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
# Production Stack -----
client-prod:
image: ghcr.io/bluewave-labs/checkmate:frontend-demo
container_name: client-prod
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://demo.checkmate.so/api/v1"
UPTIME_APP_CLIENT_HOST: "https://demo.checkmate.so"
expose:
- "80"
depends_on:
- server-prod
volumes:
- ./nginx/conf.d/prod.conf:/etc/nginx/conf.d/default.conf:ro
server-prod:
image: ghcr.io/bluewave-labs/checkmate:backend-demo
container_name: server-prod
restart: always
env_file:
- server-prod.env
depends_on:
- mongodb-prod
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
mongodb-prod:
image: ghcr.io/bluewave-labs/checkmate:mongo-demo
container_name: mongodb-prod
restart: always
command: [ "mongod", "--quiet", "--bind_ip_all" ]
volumes:
- ./mongo/data-prod:/data/db
env_file:
- mongo-prod.env
healthcheck:
test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet" ]
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
# Staging Stack -----
client-staging:
image: ghcr.io/bluewave-labs/checkmate:frontend-staging
container_name: client-staging
restart: always
environment:
UPTIME_APP_API_BASE_URL: "https://staging.checkmate.so/api/v1"
UPTIME_APP_CLIENT_HOST: "https://staging.checkmate.so"
expose:
- "80"
depends_on:
- server-staging
volumes:
- ./nginx/conf.d/staging.conf:/etc/nginx/conf.d/default.conf:ro
server-staging:
image: ghcr.io/bluewave-labs/checkmate:backend-staging
container_name: server-staging
restart: always
env_file:
- server-staging.env
depends_on:
- mongodb-staging
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
mongodb-staging:
image: ghcr.io/bluewave-labs/checkmate:mongo-staging
container_name: mongodb-staging
restart: always
command: [ "mongod", "--quiet", "--bind_ip_all" ]
volumes:
- ./mongo/data-staging:/data/db
env_file:
- mongo-staging.env
healthcheck:
test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet" ]
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
# Shared Utils
capture:
image: ghcr.io/bluewave-labs/capture:latest
container_name: capture
restart: unless-stopped
ports:
- "59232:59232"
environment:
- API_SECRET=my_secret
- GIN_MODE=release
volumes:
- /etc/os-release:/etc/os-release:ro
+55
View File
@@ -0,0 +1,55 @@
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
server {
listen 80;
server_name demo.checkmate.so staging.checkmate.so;
location /.well-known/acme-challenge/ {
# Forward the challenge to Certbot listening on 8080
proxy_pass http://172.17.0.1:8080;
}
location / {
return 301 https://$host$request_uri;
}
}
# --- PRODUCTION: demo.checkmate.so ---
server {
listen 443 ssl;
server_name demo.checkmate.so;
ssl_certificate /etc/nginx/ssl/live/demo.checkmate.so/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/demo.checkmate.so/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
# Passes traffic to the prod client on port 80
proxy_pass http://client-prod:80;
}
}
# --- STAGING: staging.checkmate.so ---
server {
listen 443 ssl;
server_name staging.checkmate.so;
ssl_certificate /etc/nginx/ssl/live/staging.checkmate.so/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/staging.checkmate.so/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
# Passes traffic to the staging client on port 80
proxy_pass http://client-staging:80;
}
}
+3
View File
@@ -0,0 +1,3 @@
FROM mongo
EXPOSE 27017
CMD ["mongod"]
+31
View File
@@ -0,0 +1,31 @@
server {
listen 80;
listen [::]:80;
server_name _;
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://server-prod:52345/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api-docs/ {
proxy_pass http://server-prod:52345/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+31
View File
@@ -0,0 +1,31 @@
server {
listen 80;
listen [::]:80;
server_name _;
server_tokens off;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://server-staging:52345/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api-docs/ {
proxy_pass http://server-staging:52345/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-slim
# Install ping
RUN apt-get update \
&& apt-get install -y iputils-ping \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_OPTIONS="--max-old-space-size=2048"
WORKDIR /app
COPY ./server/package*.json ./
RUN npm install
COPY ./server ./
RUN npm run build
EXPOSE 52345
CMD ["node", "dist/index.js"]
+1 -1
View File
@@ -35,4 +35,4 @@ If you find issues or want to improve the documentation:
- [GitHub Discussions](https://github.com/bluewave-labs/checkmate/discussions)
- [Discord Channel](https://discord.gg/NAb6H3UTjK)
- [Documentation Portal](https://docs.checkmate.so/)
- [Documentation Portal](https://checkmate.so/docs)
+31
View File
@@ -0,0 +1,31 @@
# Contributing Translations
Checkmate supports multiple languages. This guide explains how to contribute translations.
## How to Add a New Language
1. Create a new JSON file in `client/src/locales/` named with the language code (e.g., `it.json` for Italian)
2. Copy the structure from `en.json` and translate all values
3. Add the language to the language selector in the UI
## Translation Guidelines
- en.json is the reference localization file, all keys should be declared here and mirrored in other localization files
- Translate only the values
- Use formal/informal tone consistently
- Test your translations in the UI
## File Structure
client/src/locales/
├── en.json # Source language
├── es.json # Spanish
├── de.json # German
└── [lang].json # Your language
## Testing Translations
1. Start the development server: `npm run dev`
2. Go to Settings → Language
3. Select your language
4. Verify all strings display correctly
+1 -1
View File
@@ -70,5 +70,5 @@ When you select specific disks, the following elements are filtered:
## Related Documentation
- [Infrastructure Monitoring Setup](https://docs.checkmate.so/checkmate-2.1)
- [Infrastructure Monitoring Setup](https://checkmate.so/docs)
- [Capture Agent Installation](https://github.com/bluewave-labs/capture)
+1 -1
View File
@@ -8,7 +8,7 @@ Checkmate's backend is designed to be lightweight and scalable, ensuring reliabl
## Installation & documentation
For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://docs.checkmate.so).
For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://checkmate.so/docs).
## Related repositories
+73 -2
View File
@@ -597,8 +597,79 @@
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10
"maximum": 100
}
},
{
"name": "page",
"in": "query",
"description": "Zero-based page index for pagination",
"schema": {
"type": "integer",
"minimum": 0
}
},
{
"name": "rowsPerPage",
"in": "query",
"description": "Number of monitors per page",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
{
"name": "filter",
"in": "query",
"description": "Search filter value",
"schema": {
"type": "string"
}
},
{
"name": "field",
"in": "query",
"description": "Field to sort by",
"schema": {
"type": "string"
}
},
{
"name": "order",
"in": "query",
"description": "Sort order",
"schema": {
"type": "string",
"enum": ["asc", "desc"]
}
},
{
"name": "type",
"in": "query",
"description": "Filter by monitor type",
"schema": {
"oneOf": [
{
"type": "string",
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
},
{
"type": "array",
"items": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
}
}
]
}
},
{
"name": "explain",
"in": "query",
"description": "Include MongoDB query explain output (debug)",
"schema": {
"type": "boolean"
}
}
],
+33 -330
View File
@@ -14,7 +14,6 @@
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
"bullmq": "5.41.2",
"cacheable-lookup": "7.0.0",
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
@@ -22,7 +21,7 @@
"dockerode": "4.0.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"express-rate-limit": "8.3.2",
"gamedig": "5.2.0",
"got": "14.4.7",
"handlebars": "^4.7.8",
@@ -36,8 +35,7 @@
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"nodemailer": "8.0.5",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
@@ -63,7 +61,6 @@
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/pg": "8.20.0",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "^8.18.1",
@@ -3169,84 +3166,6 @@
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -4498,18 +4417,6 @@
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/ping": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz",
@@ -5534,14 +5441,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": {
@@ -6007,21 +5914,6 @@
"node": ">=10.0.0"
}
},
"node_modules/bullmq": {
"version": "5.41.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.2.tgz",
"integrity": "sha512-wqsUIHW2Td86mTKTepqQpKLLUtP4gmX89bUO1YL2fAorxwj3da1GYtroGZMCg/zgB/+zMRsbylL6DHyMUWX7fA==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
"ioredis": "^5.4.1",
"msgpackr": "^1.11.2",
"node-abort-controller": "^3.1.1",
"semver": "^7.5.4",
"tslib": "^2.0.0",
"uuid": "^9.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -7270,13 +7162,10 @@
}
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -7997,12 +7886,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.1.tgz",
"integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==",
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -8275,9 +8164,9 @@
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -9194,9 +9083,9 @@
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -11688,37 +11577,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
@@ -11828,12 +11686,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -11896,21 +11748,6 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -11925,9 +11762,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -12359,96 +12196,6 @@
"node": ">=8"
}
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -13074,45 +12821,6 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/posthtml": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz",
@@ -13293,9 +13001,9 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -13330,10 +13038,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
@@ -14158,15 +13869,6 @@
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
"license": "ISC"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -14912,6 +14614,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD"
},
"node_modules/tsx": {
+5 -6
View File
@@ -6,7 +6,9 @@
"type": "module",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services",
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
@@ -30,7 +32,6 @@
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
"bullmq": "5.41.2",
"cacheable-lookup": "7.0.0",
"compression": "1.8.1",
"cookie-parser": "^1.4.7",
@@ -38,7 +39,7 @@
"dockerode": "4.0.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"express-rate-limit": "8.3.2",
"gamedig": "5.2.0",
"got": "14.4.7",
"handlebars": "^4.7.8",
@@ -52,8 +53,7 @@
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"nodemailer": "8.0.5",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
@@ -79,7 +79,6 @@
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/pg": "8.20.0",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "^8.18.1",
+7 -34
View File
@@ -29,6 +29,8 @@ import {
MatrixProvider,
TeamsProvider,
TelegramProvider,
PushoverProvider,
TwilioProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -98,19 +100,6 @@ import {
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
TimescaleMonitorsRepository,
TimescaleChecksRepository,
TimescaleGeoChecksRepository,
TimescaleMonitorStatsRepository,
TimescaleStatusPagesRepository,
TimescaleUsersRepository,
TimescaleInvitesRepository,
TimescaleRecoveryTokensRepository,
TimescaleNotificationsRepository,
TimescaleIncidentsRepository,
TimescaleTeamsRepository,
TimescaleMaintenanceWindowsRepository,
TimescaleSettingsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
@@ -126,7 +115,6 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import TimescaleDB from "@/db/TimescaleDB.js";
import { AppError } from "@/utils/AppError.js";
export type InitializedServices = {
@@ -183,8 +171,6 @@ export const initializeServices = async ({
if (dbType === "mongodb") {
db = new MongoDB(logger, envSettings);
} else if (dbType === "timescaledb") {
db = new TimescaleDB(logger, envSettings);
}
if (!db) {
@@ -224,23 +210,7 @@ export const initializeServices = async ({
teamsRepository = new MongoTeamsRepository();
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
} else {
const pool = db.getPool();
if (!pool) {
throw new Error("Failed to get database pool");
}
monitorsRepository = new TimescaleMonitorsRepository(pool);
checksRepository = new TimescaleChecksRepository(pool);
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
usersRepository = new TimescaleUsersRepository(pool);
invitesRepository = new TimescaleInvitesRepository(pool);
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
settingsRepository = new TimescaleSettingsRepository(pool);
notificationsRepository = new TimescaleNotificationsRepository(pool);
incidentsRepository = new TimescaleIncidentsRepository(pool);
teamsRepository = new TimescaleTeamsRepository(pool);
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
throw new AppError({ message: "Unsupported database type", status: 500 });
}
// Inject settings repository into settings service (now that DB is connected)
@@ -298,6 +268,8 @@ export const initializeServices = async ({
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -310,6 +282,8 @@ export const initializeServices = async ({
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -364,7 +338,6 @@ export const initializeServices = async ({
});
const monitorService = new MonitorService({
jobQueue: superSimpleQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -29,6 +29,14 @@ export const optionalString = (value: unknown, fieldName: string): string | unde
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const extractString = (value: unknown): string | undefined => {
const candidate = Array.isArray(value) ? value[0] : value;
if (typeof candidate !== "string" || candidate.length === 0) {
return undefined;
}
return candidate;
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
+4 -3
View File
@@ -1,6 +1,6 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
import { requireTeamId, requireUserId, requireUserEmail, extractString } from "./controllerUtils.js";
import { IIncidentService } from "@/service/index.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
@@ -85,12 +85,13 @@ class IncidentController implements IIncidentController {
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const userEmail = requireUserEmail(req.user?.email);
const incidentId = req.params?.incidentId;
const incidentId = extractString(req.params?.incidentId);
if (!incidentId) {
throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 });
}
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, req?.body?.comment, userEmail);
const comment = extractString(req.body?.comment);
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, comment, userEmail);
return res.status(200).json({
success: true,
-3
View File
@@ -1,7 +1,4 @@
import { Pool } from "pg";
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPool(): Pool | null;
}
+6 -7
View File
@@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
const SERVICE_NAME = "MongoDB";
import { IDb } from "@/db/IDb.js";
import { Pool } from "pg";
class MongoDB implements IDb {
static SERVICE_NAME = SERVICE_NAME;
@@ -35,6 +34,12 @@ class MongoDB implements IDb {
setDefaultsOnInsert: true,
}
);
// Run migrations BEFORE syncIndexes. The 0006 dedupe migration must run before
// the unique index on MonitorStats.monitorId is enforced, otherwise databases
// with stray duplicates (caused by the pre-fix race in updateRunningStats)
// will crash at startup when the unique index fails to build.
await runMigrations();
// Sync indexes
const models = mongoose.modelNames();
for (const modelName of models) {
@@ -47,8 +52,6 @@ class MongoDB implements IDb {
service: SERVICE_NAME,
method: "connect",
});
await runMigrations();
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -75,10 +78,6 @@ class MongoDB implements IDb {
});
}
};
getPool = () => {
return null;
};
}
export default MongoDB;
-105
View File
@@ -1,105 +0,0 @@
import { IDb } from "@/db/IDb.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { Client, Pool } from "pg";
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
const SERVICE_NAME = "TimescaleDB";
class TimescaleDB implements IDb {
private pool: Pool | null = null;
constructor(
private logger: ILogger,
private envSettings: EnvConfig
) {}
private ensureDatabaseExists = async (connectionString: string) => {
const url = new URL(connectionString);
const dbName = url.pathname.slice(1); // Remove leading "/"
// Connect to the default "postgres" database
url.pathname = "/postgres";
const client = new Client({ connectionString: url.toString() });
await client.connect();
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
if (result.rowCount === 0) {
await client.query(`CREATE DATABASE "${dbName}"`);
this.logger.info({
message: `Created database "${dbName}"`,
service: SERVICE_NAME,
method: "ensureDatabaseExists",
});
}
await client.end();
};
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
await this.ensureDatabaseExists(connectionString);
this.pool = new Pool({ connectionString });
const client = await this.pool.connect();
client.release();
// Ensure TimescaleDB extension is enabled
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
this.logger.info({
message: "Connected to TimescaleDB",
service: SERVICE_NAME,
method: "connect",
});
await runTimescaleDBMigrations(this.pool, this.logger);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
disconnect = async () => {
try {
if (this.pool === null) {
return;
}
this.logger.info({
message: "Disconnecting from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
await this.pool.end();
this.pool = null;
this.logger.info({
message: "Disconnected from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
getPool = (): Pool => {
if (this.pool === null) {
throw new Error("TimescaleDB is not connected. Call connect() first.");
}
return this.pool;
};
}
export default TimescaleDB;
@@ -0,0 +1,78 @@
import { MonitorStatsModel } from "../models/MonitorStats.js";
import { logger } from "@/utils/logger.js";
/**
* Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced.
*
* 0003 ran the same dedupe, but the pre-fix race in StatusService.updateRunningStats
* could create new duplicates after 0003 completed. This migration re-runs the dedupe so
* the unique index added in the same PR can be built without failing.
*
* Keeps the document with the highest totalChecks for each monitorId (most data) and
* deletes the rest. Falls back to most recent updatedAt when totalChecks tie.
*/
export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex";
try {
logger.info({ service: SERVICE_NAME, message: "Starting pre-unique-index dedupe of MonitorStats" });
const duplicates = await MonitorStatsModel.aggregate([
{
$group: {
_id: "$monitorId",
docs: { $push: { id: "$_id", totalChecks: "$totalChecks", updatedAt: "$updatedAt" } },
count: { $sum: 1 },
},
},
{ $match: { count: { $gt: 1 } } },
]);
if (duplicates.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${duplicates.length} monitors with duplicate stats`,
});
let totalDeleted = 0;
type DocInfo = { id: string; totalChecks: number; updatedAt: Date };
for (const duplicate of duplicates) {
const monitorId = duplicate._id;
const docs: DocInfo[] = duplicate.docs;
// Sort by totalChecks desc, then updatedAt desc — keep the winner, delete the rest.
docs.sort((a, b) => {
const checksDiff = (b.totalChecks ?? 0) - (a.totalChecks ?? 0);
if (checksDiff !== 0) return checksDiff;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const toDelete = docs.slice(1).map((doc) => doc.id);
if (toDelete.length > 0) {
const result = await MonitorStatsModel.deleteMany({ _id: { $in: toDelete } });
totalDeleted += result.deletedCount ?? 0;
logger.debug({
service: SERVICE_NAME,
message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`,
});
}
}
logger.info({
service: SERVICE_NAME,
message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats dedupe: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -4,6 +4,7 @@ import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStat
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -17,6 +18,7 @@ const migrations: MigrationEntry[] = [
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
];
const runMigrations = async (logger?: ILogger) => {
@@ -1,50 +0,0 @@
import type { Pool } from "pg";
export const createEnums = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
};
export const dropEnums = async (pool: Pool) => {
await pool.query(`
DROP TYPE IF EXISTS match_method;
DROP TYPE IF EXISTS status_page_type;
DROP TYPE IF EXISTS duration_unit;
DROP TYPE IF EXISTS incident_resolution_type;
DROP TYPE IF EXISTS user_role;
DROP TYPE IF EXISTS notification_type;
DROP TYPE IF EXISTS monitor_status;
DROP TYPE IF EXISTS monitor_type;
`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createTeams = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email);
`);
};
export const dropTeams = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS teams;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createUsers = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
avatar_image TEXT,
profile_image BYTEA,
profile_image_content_type TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
check_ttl INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
`);
};
export const dropUsers = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS users;`);
};
@@ -1,69 +0,0 @@
import type { Pool } from "pg";
export const createMonitors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
description TEXT,
type monitor_type NOT NULL,
status monitor_status NOT NULL DEFAULT 'initializing',
-- HTTP / Website monitoring
url TEXT,
port INTEGER,
ignore_tls_errors BOOLEAN DEFAULT FALSE,
use_advanced_matching BOOLEAN DEFAULT FALSE,
json_path TEXT,
expected_value TEXT,
match_method match_method,
secret TEXT,
-- Scheduling
interval_ms INTEGER NOT NULL DEFAULT 60000,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Uptime tracking
status_window BOOLEAN[],
status_window_size INTEGER DEFAULT 5,
status_window_threshold INTEGER DEFAULT 60,
uptime_percentage DOUBLE PRECISION,
-- Infrastructure alert thresholds
cpu_alert_threshold INTEGER,
cpu_alert_counter INTEGER,
memory_alert_threshold INTEGER,
memory_alert_counter INTEGER,
disk_alert_threshold INTEGER,
disk_alert_counter INTEGER,
temp_alert_threshold INTEGER,
temp_alert_counter INTEGER,
selected_disks TEXT[],
-- Game monitoring
game_id TEXT,
grpc_service_name TEXT,
-- Grouping
monitor_group TEXT,
-- Geo checks
geo_check_enabled BOOLEAN DEFAULT FALSE,
geo_check_locations TEXT[],
geo_check_interval_ms INTEGER DEFAULT 300000,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type);
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id);
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status);
`);
};
export const dropMonitors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitors;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
type notification_type NOT NULL,
notification_name TEXT,
address TEXT,
phone TEXT,
-- Matrix-specific fields
homeserver_url TEXT,
room_id TEXT,
access_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
`);
};
export const dropNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS notifications;`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createMonitorNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_notifications (
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, notification_id)
);
CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification
ON monitor_notifications (notification_id);
`);
};
export const dropMonitorNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createIncidents = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
status BOOLEAN,
message TEXT,
status_code INTEGER,
resolution_type incident_resolution_type,
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
resolved_by_email TEXT,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status);
CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status);
CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC);
`);
};
export const dropIncidents = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS incidents;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createMonitorStats = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE,
avg_response_time DOUBLE PRECISION DEFAULT 0,
max_response_time DOUBLE PRECISION DEFAULT 0,
total_checks BIGINT DEFAULT 0,
total_up_checks BIGINT DEFAULT 0,
total_down_checks BIGINT DEFAULT 0,
uptime_percentage DOUBLE PRECISION DEFAULT 0,
last_check_timestamp TIMESTAMPTZ,
last_response_time DOUBLE PRECISION DEFAULT 0,
time_of_last_failure TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
export const dropMonitorStats = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_stats;`);
};
@@ -1,105 +0,0 @@
import type { Pool } from "pg";
export const createChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
status BOOLEAN,
response_time INTEGER,
status_code INTEGER,
message TEXT,
-- HTTP request timings
timing_start DOUBLE PRECISION,
timing_socket DOUBLE PRECISION,
timing_lookup DOUBLE PRECISION,
timing_connect DOUBLE PRECISION,
timing_secure_connect DOUBLE PRECISION,
timing_upload DOUBLE PRECISION,
timing_response DOUBLE PRECISION,
timing_end DOUBLE PRECISION,
phase_wait DOUBLE PRECISION,
phase_dns DOUBLE PRECISION,
phase_tcp DOUBLE PRECISION,
phase_tls DOUBLE PRECISION,
phase_request DOUBLE PRECISION,
phase_first_byte DOUBLE PRECISION,
phase_download DOUBLE PRECISION,
phase_total DOUBLE PRECISION,
-- CPU metrics
cpu_physical_core INTEGER,
cpu_logical_core INTEGER,
cpu_frequency DOUBLE PRECISION,
cpu_current_frequency DOUBLE PRECISION,
cpu_temperature DOUBLE PRECISION[],
cpu_free_percent DOUBLE PRECISION,
cpu_usage_percent DOUBLE PRECISION,
-- Memory metrics
mem_total_bytes BIGINT,
mem_available_bytes BIGINT,
mem_used_bytes BIGINT,
mem_usage_percent DOUBLE PRECISION,
-- Host info
host_os TEXT,
host_platform TEXT,
host_kernel_version TEXT,
host_pretty_name TEXT,
-- Capture agent info
capture_version TEXT,
capture_mode TEXT,
-- Lighthouse / PageSpeed scores
lighthouse_performance DOUBLE PRECISION,
lighthouse_accessibility DOUBLE PRECISION,
lighthouse_best_practices DOUBLE PRECISION,
lighthouse_seo DOUBLE PRECISION,
-- Lighthouse audit details
audit_cls_score DOUBLE PRECISION,
audit_cls_value DOUBLE PRECISION,
audit_cls_display TEXT,
audit_si_score DOUBLE PRECISION,
audit_si_value DOUBLE PRECISION,
audit_si_display TEXT,
audit_fcp_score DOUBLE PRECISION,
audit_fcp_value DOUBLE PRECISION,
audit_fcp_display TEXT,
audit_lcp_score DOUBLE PRECISION,
audit_lcp_value DOUBLE PRECISION,
audit_lcp_display TEXT,
audit_tbt_score DOUBLE PRECISION,
audit_tbt_value DOUBLE PRECISION,
audit_tbt_display TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
ON checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_time
ON checks (team_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time
ON checks (monitor_id, monitor_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_status_time
ON checks (team_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_status_code
ON checks (status_code, created_at DESC);
`);
};
export const dropChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS checks;`);
};
@@ -1,37 +0,0 @@
import type { Pool } from "pg";
export const createCheckDisks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_disks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
device TEXT,
mountpoint TEXT,
total_bytes BIGINT,
free_bytes BIGINT,
used_bytes BIGINT,
usage_percent DOUBLE PRECISION,
total_inodes BIGINT,
free_inodes BIGINT,
used_inodes BIGINT,
inodes_usage_percent DOUBLE PRECISION,
read_bytes BIGINT,
write_bytes BIGINT,
read_time BIGINT,
write_time BIGINT
);
SELECT create_hypertable('check_disks', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_disks_check
ON check_disks (check_id, check_created_at);
`);
};
export const dropCheckDisks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_disks;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_network_interfaces (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
name TEXT,
bytes_sent BIGINT,
bytes_recv BIGINT,
packets_sent BIGINT,
packets_recv BIGINT,
err_in BIGINT,
err_out BIGINT,
drop_in BIGINT,
drop_out BIGINT,
fifo_in BIGINT,
fifo_out BIGINT
);
SELECT create_hypertable('check_network_interfaces', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_net_check
ON check_network_interfaces (check_id, check_created_at);
`);
};
export const dropCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createCheckErrors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_errors (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
metrics TEXT[],
error TEXT
);
SELECT create_hypertable('check_errors', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_errors_check
ON check_errors (check_id, check_created_at);
`);
};
export const dropCheckErrors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_errors;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createGeoChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('geo_checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time
ON geo_checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time
ON geo_checks (team_id, created_at DESC);
`);
};
export const dropGeoChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_checks;`);
};
@@ -1,46 +0,0 @@
import type { Pool } from "pg";
export const createGeoCheckResults = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_check_results (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geo_check_id UUID NOT NULL,
geo_check_created_at TIMESTAMPTZ NOT NULL,
-- Status
status BOOLEAN,
status_code INTEGER,
-- Location
location_continent TEXT,
location_region TEXT,
location_country TEXT,
location_state TEXT,
location_city TEXT,
location_longitude DOUBLE PRECISION,
location_latitude DOUBLE PRECISION,
-- Timings
timing_total DOUBLE PRECISION,
timing_dns DOUBLE PRECISION,
timing_tcp DOUBLE PRECISION,
timing_tls DOUBLE PRECISION,
timing_first_byte DOUBLE PRECISION,
timing_download DOUBLE PRECISION
);
SELECT create_hypertable('geo_check_results', 'geo_check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_results_check
ON geo_check_results (geo_check_id, geo_check_created_at);
CREATE INDEX IF NOT EXISTS idx_geo_results_country
ON geo_check_results (location_country, geo_check_created_at DESC);
`);
};
export const dropGeoCheckResults = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_check_results;`);
};
@@ -1,22 +0,0 @@
import type { Pool } from "pg";
export const createInvites = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry);
`);
};
export const dropInvites = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS invites;`);
};
@@ -1,20 +0,0 @@
import type { Pool } from "pg";
export const createRecoveryTokens = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS recovery_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry);
`);
};
export const dropRecoveryTokens = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createMaintenanceWindows = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
active BOOLEAN NOT NULL DEFAULT TRUE,
name TEXT,
duration INTEGER,
duration_unit duration_unit,
repeat INTEGER,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id);
CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id);
CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry);
`);
};
export const dropMaintenanceWindows = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`);
};
@@ -1,51 +0,0 @@
import type { Pool } from "pg";
export const createStatusPages = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[],
company_name TEXT,
url TEXT NOT NULL UNIQUE,
timezone TEXT,
color TEXT DEFAULT '#4169E1',
logo_data BYTEA,
logo_content_type TEXT,
is_published BOOLEAN DEFAULT FALSE,
show_charts BOOLEAN DEFAULT FALSE,
show_uptime_percentage BOOLEAN DEFAULT FALSE,
show_admin_login_link BOOLEAN DEFAULT FALSE,
show_infrastructure BOOLEAN DEFAULT FALSE,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id);
CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url);
CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
CREATE TABLE IF NOT EXISTS status_page_sub_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
`);
};
export const dropStatusPages = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS status_page_sub_monitors;
DROP TABLE IF EXISTS status_page_monitors;
DROP TABLE IF EXISTS status_pages;
`);
};
@@ -1,62 +0,0 @@
import type { Pool } from "pg";
export const createAppSettings = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS app_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_ttl INTEGER DEFAULT 30,
language TEXT,
jwt_secret TEXT,
pagespeed_api_key TEXT,
-- SMTP / Email settings
system_email_host TEXT,
system_email_port INTEGER,
system_email_address TEXT,
system_email_password TEXT,
system_email_user TEXT,
system_email_connection_host TEXT,
system_email_tls_servername TEXT,
system_email_secure BOOLEAN,
system_email_pool BOOLEAN,
system_email_ignore_tls BOOLEAN,
system_email_require_tls BOOLEAN,
system_email_reject_unauthorized BOOLEAN,
show_url BOOLEAN,
version TEXT,
-- Global infrastructure thresholds
threshold_cpu_usage INTEGER,
threshold_memory_usage INTEGER,
threshold_disk_usage INTEGER,
threshold_temperature INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enforce singleton: only one row allowed
CREATE OR REPLACE FUNCTION enforce_singleton_app_settings()
RETURNS TRIGGER AS $$
BEGIN
IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN
RAISE EXCEPTION 'app_settings allows only one row';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings;
CREATE TRIGGER trg_singleton_app_settings
BEFORE INSERT ON app_settings
FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings();
`);
};
export const dropAppSettings = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS app_settings;
DROP FUNCTION IF EXISTS enforce_singleton_app_settings();
`);
};
@@ -1,127 +0,0 @@
import type { Pool } from "pg";
export const createContinuousAggregates = async (pool: Pool) => {
// Uptime & Response Time (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time,
MIN(response_time) AS min_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`);
// Uptime & Response Time (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`);
// Infrastructure Metrics (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
AVG(cpu_usage_percent) AS avg_cpu,
MAX(cpu_usage_percent) AS max_cpu,
AVG(mem_usage_percent) AS avg_memory,
MAX(mem_usage_percent) AS max_memory,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'hardware'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('hardware_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`);
// PageSpeed Scores (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
AVG(lighthouse_performance) AS avg_performance,
AVG(lighthouse_accessibility) AS avg_accessibility,
AVG(lighthouse_best_practices) AS avg_best_practices,
AVG(lighthouse_seo) AS avg_seo,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'pagespeed'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('pagespeed_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`);
};
export const dropContinuousAggregates = async (pool: Pool) => {
await pool.query(`
DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE;
`);
};
@@ -1,135 +0,0 @@
import type { Pool } from "pg";
export const createRetentionCompression = async (pool: Pool) => {
// -- Compression policies --
// Checks
await pool.query(`
ALTER TABLE checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Check child tables
await pool.query(`
ALTER TABLE check_disks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_network_interfaces SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_errors SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Geo checks
await pool.query(`
ALTER TABLE geo_checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE geo_check_results SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'geo_check_id',
timescaledb.compress_orderby = 'geo_check_created_at DESC'
);
SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE);
`);
// -- Retention policies (default 30 days) --
await pool.query(`
SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE);
`);
// -- Dynamic retention helper function --
await pool.query(`
CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER)
RETURNS VOID AS $$
DECLARE
ttl_interval INTERVAL;
BEGIN
ttl_interval := (new_ttl_days || ' days')::INTERVAL;
PERFORM remove_retention_policy('checks', if_exists => true);
PERFORM remove_retention_policy('check_disks', if_exists => true);
PERFORM remove_retention_policy('check_network_interfaces', if_exists => true);
PERFORM remove_retention_policy('check_errors', if_exists => true);
PERFORM remove_retention_policy('geo_checks', if_exists => true);
PERFORM remove_retention_policy('geo_check_results', if_exists => true);
PERFORM add_retention_policy('checks', ttl_interval);
PERFORM add_retention_policy('check_disks', ttl_interval);
PERFORM add_retention_policy('check_network_interfaces', ttl_interval);
PERFORM add_retention_policy('check_errors', ttl_interval);
PERFORM add_retention_policy('geo_checks', ttl_interval);
PERFORM add_retention_policy('geo_check_results', ttl_interval);
END;
$$ LANGUAGE plpgsql;
`);
};
export const dropRetentionCompression = async (pool: Pool) => {
// Remove retention policies
await pool.query(`
SELECT remove_retention_policy('checks', if_exists => true);
SELECT remove_retention_policy('check_disks', if_exists => true);
SELECT remove_retention_policy('check_network_interfaces', if_exists => true);
SELECT remove_retention_policy('check_errors', if_exists => true);
SELECT remove_retention_policy('geo_checks', if_exists => true);
SELECT remove_retention_policy('geo_check_results', if_exists => true);
`);
// Remove compression policies
await pool.query(`
SELECT remove_compression_policy('checks', if_exists => true);
SELECT remove_compression_policy('check_disks', if_exists => true);
SELECT remove_compression_policy('check_network_interfaces', if_exists => true);
SELECT remove_compression_policy('check_errors', if_exists => true);
SELECT remove_compression_policy('geo_checks', if_exists => true);
SELECT remove_compression_policy('geo_check_results', if_exists => true);
`);
// Disable compression on tables
await pool.query(`
ALTER TABLE checks SET (timescaledb.compress = false);
ALTER TABLE check_disks SET (timescaledb.compress = false);
ALTER TABLE check_network_interfaces SET (timescaledb.compress = false);
ALTER TABLE check_errors SET (timescaledb.compress = false);
ALTER TABLE geo_checks SET (timescaledb.compress = false);
ALTER TABLE geo_check_results SET (timescaledb.compress = false);
`);
// Drop dynamic retention function
await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`);
};
@@ -1,148 +0,0 @@
import type { Pool } from "pg";
import type { ILogger } from "@/utils/logger.js";
import { createEnums, dropEnums } from "./0001_create_enums.js";
import { createTeams, dropTeams } from "./0002_create_teams.js";
import { createUsers, dropUsers } from "./0003_create_users.js";
import { createMonitors, dropMonitors } from "./0004_create_monitors.js";
import { createNotifications, dropNotifications } from "./0005_create_notifications.js";
import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js";
import { createIncidents, dropIncidents } from "./0007_create_incidents.js";
import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js";
import { createChecks, dropChecks } from "./0009_create_checks.js";
import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js";
import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js";
import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js";
import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js";
import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js";
import { createInvites, dropInvites } from "./0015_create_invites.js";
import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js";
import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js";
import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js";
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
const SERVICE_NAME = "TimescaleDB Migrations";
type MigrationEntry = {
name: string;
up: (pool: Pool) => Promise<void>;
down: (pool: Pool) => Promise<void>;
};
const migrations: MigrationEntry[] = [
{ name: "0001_create_enums", up: createEnums, down: dropEnums },
{ name: "0002_create_teams", up: createTeams, down: dropTeams },
{ name: "0003_create_users", up: createUsers, down: dropUsers },
{ name: "0004_create_monitors", up: createMonitors, down: dropMonitors },
{ name: "0005_create_notifications", up: createNotifications, down: dropNotifications },
{ name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications },
{ name: "0007_create_incidents", up: createIncidents, down: dropIncidents },
{ name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats },
{ name: "0009_create_checks", up: createChecks, down: dropChecks },
{ name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks },
{ name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces },
{ name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors },
{ name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks },
{ name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults },
{ name: "0015_create_invites", up: createInvites, down: dropInvites },
{ name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens },
{ name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows },
{ name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages },
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
];
const ensureMigrationsTable = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE migration_status AS ENUM ('completed', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
status migration_status NOT NULL,
completed_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => {
try {
logger?.info({ message: "Running migrations", service: SERVICE_NAME });
await ensureMigrationsTable(pool);
for (const migration of migrations) {
const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]);
if (result.rowCount !== null && result.rowCount > 0) {
logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME });
continue;
}
try {
await migration.up(pool);
await pool.query(
`INSERT INTO migrations (name, status, completed_at)
VALUES ($1, 'completed', NOW())
ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`,
[migration.name]
);
logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
await pool.query(
`INSERT INTO migrations (name, status, error)
VALUES ($1, 'failed', $2)
ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`,
[migration.name, err?.message]
);
throw error;
}
}
logger?.info({ message: "Migrations completed", service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => {
try {
await ensureMigrationsTable(pool);
// Find the last completed migration
const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1");
if (result.rowCount === 0) {
logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME });
return;
}
const lastMigrationName = result.rows[0].name;
const migration = migrations.find((m) => m.name === lastMigrationName);
if (!migration) {
throw new Error(`Migration "${lastMigrationName}" not found in migration list`);
}
logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME });
await migration.down(pool);
await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]);
logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };
+1
View File
@@ -271,6 +271,7 @@ const CheckSchema = new Schema<CheckDocument>(
{
timestamps: true,
strict: false,
suppressReservedKeysWarning: true,
timeseries: {
timeField: "createdAt",
metaField: "metadata",
+1 -1
View File
@@ -16,7 +16,7 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
index: true,
unique: true,
required: true,
},
avgResponseTime: {
+3 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
required: true,
},
notificationName: {
@@ -37,6 +37,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
homeserverUrl: { type: String },
roomId: { type: String },
accessToken: { type: String },
accountSid: { type: String },
twilioPhoneNumber: { type: String },
},
{
timestamps: true,
File diff suppressed because it is too large. Load diff
@@ -1,274 +0,0 @@
import type { Pool } from "pg";
import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js";
import { getDateForRange } from "@/utils/dataUtils.js";
const dateStringToBucket = (dateString: string): string => {
if (dateString.includes("%M")) return "1 minute";
if (dateString.includes("%H")) return "1 hour";
return "1 day";
};
export class TimescaleGeoChecksRepository implements IGeoChecksRepository {
constructor(private pool: Pool) {}
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
const created: GeoCheck[] = [];
for (const geoCheck of geoChecks) {
const geoResult = await this.pool.query(
`INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry)
VALUES ($1, $2, $3, $4)
RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`,
[geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null]
);
const row = geoResult.rows[0];
if (!row) continue;
for (const result of geoCheck.results) {
await this.pool.query(
`INSERT INTO geo_check_results (
geo_check_id, geo_check_created_at,
status, status_code,
location_continent, location_region, location_country, location_state, location_city,
location_longitude, location_latitude,
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
[
row.id,
row.created_at,
result.status,
result.statusCode,
result.location.continent,
result.location.region,
result.location.country,
result.location.state,
result.location.city,
result.location.longitude,
result.location.latitude,
result.timings.total,
result.timings.dns,
result.timings.tcp,
result.timings.tls,
result.timings.firstByte,
result.timings.download,
]
);
}
created.push(this.toGeoCheck(row, geoCheck.results));
}
return created;
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
const conditions: string[] = ["gc.monitor_id = $1"];
const values: unknown[] = [monitorId];
let paramIndex = 2;
const rangeDate = getDateForRange(dateRange);
if (rangeDate) {
conditions.push(`gc.created_at >= $${paramIndex++}`);
values.push(rangeDate);
}
if (continents && continents.length > 0) {
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
values.push(continents);
}
const where = conditions.join(" AND ");
const direction = sortOrder === "asc" ? "ASC" : "DESC";
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
const countValues = [...values];
const dataValues = [...values, rowsPerPage, offset];
const [countResult, dataResult] = await Promise.all([
this.pool.query(
`SELECT COUNT(*)::int AS count
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${where}`,
countValues
),
this.pool.query(
`SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type,
r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city,
r.location_longitude, r.location_latitude,
r.status, r.status_code,
r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download,
gc.created_at, gc.updated_at
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${where}
ORDER BY gc.created_at ${direction}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataValues
),
]);
const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({
id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`,
monitorId: row.monitor_id,
teamId: row.team_id,
type: row.monitor_type,
location: {
continent: row.location_continent,
region: row.location_region,
country: row.location_country,
state: row.location_state,
city: row.location_city,
longitude: row.location_longitude,
latitude: row.location_latitude,
},
status: row.status,
statusCode: row.status_code,
timings: {
total: row.timing_total ?? 0,
dns: row.timing_dns ?? 0,
tcp: row.timing_tcp ?? 0,
tls: row.timing_tls ?? 0,
firstByte: row.timing_first_byte ?? 0,
download: row.timing_download ?? 0,
},
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
}));
return { geoChecksCount: countResult.rows[0].count, geoChecks };
};
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
const geoRows = await this.pool.query(
`SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at
FROM geo_checks
WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3
ORDER BY created_at DESC`,
[monitorId, startDate, endDate]
);
const geoChecks: GeoCheck[] = [];
for (const row of geoRows.rows) {
const resultsRows = await this.pool.query(
`SELECT status, status_code,
location_continent, location_region, location_country, location_state, location_city,
location_longitude, location_latitude,
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
FROM geo_check_results
WHERE geo_check_id = $1`,
[row.id]
);
const results = resultsRows.rows.map(this.toGeoCheckResult);
geoChecks.push(this.toGeoCheck(row, results));
}
return geoChecks;
};
findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
const bucket = dateStringToBucket(dateFormat);
const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"];
const values: unknown[] = [bucket, monitorId, startDate, endDate];
let paramIndex = 5;
if (continents && continents.length > 0) {
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
values.push(continents);
}
const result = await this.pool.query(
`SELECT
time_bucket($1::interval, gc.created_at) AS bucket_date,
r.location_continent AS continent,
ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime",
COUNT(*)::int AS "totalChecks",
ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage"
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${conditions.join(" AND ")}
GROUP BY bucket_date, r.location_continent
ORDER BY bucket_date, r.location_continent`,
values
);
return result.rows.map((row) => ({
bucketDate: (row.bucket_date as Date).toISOString(),
continent: row.continent as GeoContinent,
avgResponseTime: Number(row.avgResponseTime ?? 0),
totalChecks: Number(row.totalChecks ?? 0),
uptimePercentage: Number(row.uptimePercentage ?? 0),
}));
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]);
return result.rowCount ?? 0;
};
deleteByTeamId = async (teamId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM geo_checks`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private toGeoCheckResult = (row: Record<string, unknown>): GeoCheckResult => ({
location: {
continent: (row.location_continent as GeoContinent) ?? "",
region: (row.location_region as string) ?? "",
country: (row.location_country as string) ?? "",
state: (row.location_state as string) ?? "",
city: (row.location_city as string) ?? "",
longitude: (row.location_longitude as number) ?? 0,
latitude: (row.location_latitude as number) ?? 0,
},
status: (row.status as boolean) ?? false,
statusCode: (row.status_code as number) ?? 0,
timings: {
total: (row.timing_total as number) ?? 0,
dns: (row.timing_dns as number) ?? 0,
tcp: (row.timing_tcp as number) ?? 0,
tls: (row.timing_tls as number) ?? 0,
firstByte: (row.timing_first_byte as number) ?? 0,
download: (row.timing_download as number) ?? 0,
},
});
private toGeoCheck = (row: Record<string, unknown>, results: GeoCheckResult[]): GeoCheck => ({
id: row.id as string,
metadata: {
monitorId: row.monitor_id as string,
teamId: row.team_id as string,
type: row.monitor_type as MonitorType,
},
results,
expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(),
__v: 0,
createdAt: (row.created_at as Date).toISOString(),
updatedAt: (row.updated_at as Date).toISOString(),
});
}
@@ -1,294 +0,0 @@
import type { Pool } from "pg";
import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js";
import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js";
import { AppError } from "@/utils/AppError.js";
interface IncidentRow {
id: string;
monitor_id: string;
team_id: string;
start_time: Date;
end_time: Date | null;
status: boolean;
message: string | null;
status_code: number | null;
resolution_type: IncidentResolutionType;
resolved_by: string | null;
resolved_by_email: string | null;
comment: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code,
resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`;
export class TimescaleIncidentsRepository implements IIncidentsRepository {
constructor(private pool: Pool) {}
create = async (incident: Partial<Incident>): Promise<Incident> => {
const result = await this.pool.query<IncidentRow>(
`INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING ${COLUMNS}`,
[
incident.monitorId,
incident.teamId,
incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(),
incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null,
incident.status ?? true,
incident.message ?? null,
incident.statusCode ?? null,
incident.resolutionType ?? null,
incident.resolvedBy ?? null,
incident.resolvedByEmail ?? null,
incident.comment ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create incident", status: 500 });
}
return this.toEntity(row);
};
findById = async (incidentId: string, teamId: string): Promise<Incident> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 });
}
return this.toEntity(row);
};
findActiveByIncidentId = async (incidentId: string, teamId: string): Promise<Incident | null> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [
incidentId,
teamId,
]);
const row = result.rows[0];
return row ? this.toEntity(row) : null;
};
findActiveByMonitorId = async (monitorId: string, teamId: string): Promise<Incident | null> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [
monitorId,
teamId,
]);
const row = result.rows[0];
return row ? this.toEntity(row) : null;
};
findByTeamId = async (
teamId: string,
startDate: Date | undefined,
page: number,
rowsPerPage: number,
sortOrder?: string,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<Incident[]> => {
const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
const direction = sortOrder === "asc" ? "ASC" : "DESC";
const offset = page * rowsPerPage;
const result = await this.pool.query<IncidentRow>(
`SELECT ${COLUMNS} FROM incidents
WHERE ${conditions.join(" AND ")}
ORDER BY created_at ${direction}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, rowsPerPage, offset]
);
return result.rows.map(this.toEntity);
};
countByTeamId = async (
teamId: string,
startDate: Date | undefined,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<number> => {
const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values);
return result.rows[0].count;
};
findSummaryByTeamId = async (teamId: string, limit?: number): Promise<IncidentSummary> => {
// Counts by status and resolution type
const countsResult = await this.pool.query(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status = TRUE)::int AS active,
COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual,
COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic
FROM incidents WHERE team_id = $1`,
[teamId]
);
const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 };
// Average resolution time
const resTimeResult = await this.pool.query(
`SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds
FROM incidents
WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`,
[teamId]
);
const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0;
const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100;
// Top monitor by incident count
const topMonitorResult = await this.pool.query(
`SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count
FROM incidents i
LEFT JOIN monitors m ON m.id = i.monitor_id
WHERE i.team_id = $1
GROUP BY i.monitor_id, m.name
ORDER BY count DESC
LIMIT 1`,
[teamId]
);
// Latest incidents
const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10);
const latestResult = await this.pool.query(
`SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time,
i.resolution_type, i.message, i.status_code, i.created_at
FROM incidents i
LEFT JOIN monitors m ON m.id = i.monitor_id
WHERE i.team_id = $1
ORDER BY i.created_at DESC
LIMIT $2`,
[teamId, latestLimit]
);
const topRow = topMonitorResult.rows[0];
return {
total: counts.total,
totalActive: counts.active,
totalManualResolutions: counts.manual,
totalAutomaticResolutions: counts.automatic,
avgResolutionTimeHours,
topMonitor: topRow
? {
monitorId: topRow.monitor_id,
monitorName: topRow.monitor_name ?? null,
incidentCount: topRow.count,
}
: null,
latestIncidents: latestResult.rows.map((row) => ({
id: row.id,
monitorId: row.monitor_id,
monitorName: row.monitor_name ?? null,
status: row.status,
startTime: row.start_time.toISOString(),
endTime: row.end_time ? row.end_time.toISOString() : null,
resolutionType: row.resolution_type ?? null,
message: row.message ?? null,
statusCode: row.status_code ?? null,
createdAt: row.created_at.toISOString(),
})),
};
};
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>): Promise<Incident> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof Incident, string][] = [
["status", "status"],
["message", "message"],
["statusCode", "status_code"],
["endTime", "end_time"],
["resolutionType", "resolution_type"],
["resolvedBy", "resolved_by"],
["resolvedByEmail", "resolved_by_email"],
["comment", "comment"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key];
sets.push(`${column} = $${paramIndex++}`);
values.push(value);
}
}
if (sets.length === 0) {
return this.findById(incidentId, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(incidentId, teamId);
const result = await this.pool.query<IncidentRow>(
`UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 });
}
return this.toEntity(row);
};
deleteByMonitorId = async (monitorId: string, teamId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM incidents`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
let paramIndex = 2;
if (startDate) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(startDate);
}
if (status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
values.push(status);
}
if (monitorId) {
conditions.push(`monitor_id = $${paramIndex++}`);
values.push(monitorId);
}
if (resolutionType) {
conditions.push(`resolution_type = $${paramIndex++}`);
values.push(resolutionType);
}
return { conditions, values, paramIndex };
};
private toEntity = (row: IncidentRow): Incident => ({
id: row.id,
monitorId: row.monitor_id,
teamId: row.team_id,
startTime: row.start_time.toISOString(),
endTime: row.end_time ? row.end_time.toISOString() : null,
status: row.status,
message: row.message ?? null,
statusCode: row.status_code ?? null,
resolutionType: row.resolution_type ?? null,
resolvedBy: row.resolved_by ?? null,
resolvedByEmail: row.resolved_by_email ?? null,
comment: row.comment ?? null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
-13
View File
@@ -1,54 +1,41 @@
export * from "@/repositories/monitors/IMonitorsRepository.js";
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js";
export * from "@/repositories/checks/IChecksRepository.js";
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js";
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js";
export * from "@/repositories/status-pages/IStatusPagesRepository.js";
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js";
export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js";
export * from "@/repositories/users/IUsersRepository.js";
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js";
export * from "@/repositories/invites/IInvitesRepository.js";
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js";
export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js";
export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js";
export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js";
export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js";
export * from "@/repositories/settings/ISettingsRepository.js";
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js";
export * from "@/repositories/notifications/INotificationsRepository.js";
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js";
export * from "@/repositories/incidents/IIncidentsRepository.js";
export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js";
export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js";
export * from "@/repositories/teams/ITeamsRepository.js";
export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js";
export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js";
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js";
export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
export * from "@/repositories/dlq/IDLQRepository.js";
export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js";
export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js";
@@ -1,77 +0,0 @@
import type { Pool } from "pg";
import crypto from "crypto";
import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js";
import type { Invite } from "@/types/invite.js";
import type { UserRole } from "@/types/user.js";
import { AppError } from "@/utils/AppError.js";
const parsePostgresArray = (value: unknown): UserRole[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as UserRole[];
};
interface InviteRow {
id: string;
email: string;
team_id: string;
roles: UserRole[];
token: string;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`;
export class TimescaleInvitesRepository implements IInvitesRepository {
constructor(private pool: Pool) {}
create = async (invite: Partial<Invite>): Promise<Invite> => {
// Delete existing invites for this email
await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]);
const token = crypto.randomBytes(32).toString("hex");
const result = await this.pool.query<InviteRow>(
`INSERT INTO invites (email, team_id, roles, token, expiry)
VALUES ($1, $2, $3, $4, $5)
RETURNING ${COLUMNS}`,
[invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create invite", status: 500 });
}
return this.toEntity(row);
};
findByToken = async (token: string): Promise<Invite> => {
const result = await this.pool.query<InviteRow>(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(row);
};
findByTokenAndDelete = async (token: string): Promise<Invite> => {
const result = await this.pool.query<InviteRow>(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(row);
};
private toEntity = (row: InviteRow): Invite => ({
id: row.id,
email: row.email,
teamId: row.team_id,
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
token: row.token,
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,197 +0,0 @@
import type { Pool } from "pg";
import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js";
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
import { AppError } from "@/utils/AppError.js";
interface MaintenanceWindowRow {
id: string;
monitor_id: string;
team_id: string;
active: boolean;
name: string;
duration: number;
duration_unit: DurationUnit;
repeat: number;
start_time: Date;
end_time: Date;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat,
start_time, end_time, expiry, created_at, updated_at`;
export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository {
constructor(private pool: Pool) {}
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const startTime = data.start ? new Date(data.start) : null;
const endTime = data.end ? new Date(data.end) : null;
// One-time windows expire at end time
const expiry = data.repeat === 0 ? endTime : null;
const result = await this.pool.query<MaintenanceWindowRow>(
`INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING ${COLUMNS}`,
[
data.monitorId,
data.teamId,
data.active ?? true,
data.name ?? null,
data.duration ?? null,
data.durationUnit ?? null,
data.repeat ?? 0,
startTime,
endTime,
expiry,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create maintenance window", status: 500 });
}
return this.toEntity(row);
};
findById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [
id,
teamId,
]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance Window not found", status: 404 });
}
return this.toEntity(row);
};
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [
monitorId,
teamId,
]);
return result.rows.map(this.toEntity);
};
findByTeamId = async (
teamId: string,
page: number,
rowsPerPage: number,
field?: string,
order?: string,
active?: boolean
): Promise<MaintenanceWindow[]> => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
let paramIndex = 2;
if (active !== undefined) {
conditions.push(`active = $${paramIndex++}`);
values.push(active);
}
const fieldMap: Record<string, string> = {
createdAt: "created_at",
name: "name",
start: "start_time",
end: "end_time",
active: "active",
};
const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at";
const sortDirection = order === "asc" ? "ASC" : "DESC";
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
const result = await this.pool.query<MaintenanceWindowRow>(
`SELECT ${COLUMNS} FROM maintenance_windows
WHERE ${conditions.join(" AND ")}
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
[...values, rowsPerPage, offset]
);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, teamId: string, patch: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [
["active", "active", false],
["name", "name", false],
["duration", "duration", false],
["durationUnit", "duration_unit", false],
["repeat", "repeat", false],
["start", "start_time", true],
["end", "end_time", true],
];
for (const [key, column, isDate] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(isDate ? new Date(patch[key] as string) : patch[key]);
}
}
if (sets.length === 0) {
return this.findById(id, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<MaintenanceWindowRow>(
`UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
const result = await this.pool.query<MaintenanceWindowRow>(
`DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`,
[id, teamId]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 });
}
return this.toEntity(row);
};
countByTeamId = async (teamId: string, active?: boolean): Promise<number> => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
if (active !== undefined) {
conditions.push(`active = $2`);
values.push(active);
}
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values);
return result.rows[0].count;
};
private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({
id: row.id,
monitorId: row.monitor_id,
teamId: row.team_id,
active: row.active,
name: row.name,
duration: row.duration,
durationUnit: row.duration_unit,
repeat: row.repeat,
start: row.start_time.toISOString(),
end: row.end_time.toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,11 +1,12 @@
import type { MonitorStats } from "@/types/index.js";
import type { MonitorStats, CheckResultInput } from "@/types/index.js";
export interface IMonitorStatsRepository {
// create
create(data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// single fetch
findByMonitorId(monitorId: string): Promise<MonitorStats>;
// update
updateByMonitorId(monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// Upsert must be atomic to prevent race conditions
updateByMonitorId(monitorId: string, result: CheckResultInput): Promise<MonitorStats>;
// delete
deleteByMonitorId(monitorId: string): Promise<MonitorStats>;
deleteByMonitorIds(monitorIds: string[]): Promise<number>;
@@ -1,5 +1,5 @@
import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js";
import type { MonitorStats } from "@/types/index.js";
import type { MonitorStats, CheckResultInput } from "@/types/index.js";
import { IMonitorStatsRepository } from "@/repositories/index.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
@@ -46,10 +46,48 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
return this.toEntity(monitorStats);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true });
updateByMonitorId = async (monitorId: string, result: CheckResultInput): Promise<MonitorStats> => {
const { status, responseTime, now } = result;
const objectId = new mongoose.Types.ObjectId(monitorId);
const upDelta = status ? 1 : 0;
const downDelta = status ? 0 : 1;
const pipeline = [
{
$set: {
monitorId: objectId,
totalChecks: { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] },
totalUpChecks: { $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] },
totalDownChecks: { $add: [{ $ifNull: ["$totalDownChecks", 0] }, downDelta] },
avgResponseTime: {
$divide: [
{
$add: [{ $multiply: [{ $ifNull: ["$avgResponseTime", 0] }, { $ifNull: ["$totalChecks", 0] }] }, responseTime],
},
{ $add: [{ $ifNull: ["$totalChecks", 0] }, 1] },
],
},
uptimePercentage: {
$divide: [{ $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] }, { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] }],
},
maxResponseTime: { $max: [{ $ifNull: ["$maxResponseTime", 0] }, responseTime] },
lastResponseTime: responseTime,
lastCheckTimestamp: now,
timeOfLastFailure: status
? {
$cond: [{ $eq: [{ $ifNull: ["$timeOfLastFailure", 0] }, 0] }, now, "$timeOfLastFailure"],
}
: 0,
},
},
];
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: objectId }, pipeline, {
upsert: true,
new: true,
setDefaultsOnInsert: true,
});
if (!updated) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
throw new AppError({ message: "Failed to apply check result to monitor stats", status: 500 });
}
return this.toEntity(updated);
};
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more