Compare commits

..
Author SHA1 Message Date
Alex Holliday dc7688bc13 initialize timeOfLastFailure 2026-04-13 10:58:58 -07: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
Alex Holliday db378120be add queue tests 2026-04-08 12:03:26 -07:00
Alex Holliday ba5368f942 add notificationsService tests 2026-04-08 11:32:08 -07:00
Alex Holliday 7329306ccf add incident serivce tests 2026-04-08 10:43:47 -07:00
Alex Holliday 365987c287 relocate tests, add statusService tests 2026-04-08 10:31:24 -07:00
Gorkem Cetin 09d6147f75 Remove Translations section from README
Removed the Translations section from the README.
2026-04-08 01:26:16 -04:00
Gorkem Cetin 86ab1f2146 Add Coolify and Cloudzy 2026-04-07 18:34:28 -04:00
Alexander Holliday 934f85f505 Merge pull request #3460 from bluewave-labs/fix/server-test-suite-failures
fix: align server test suite with current source implementations
2026-04-07 11:11:51 -07:00
Alex Holliday 96beda5c47 update mock logger and tests 2026-04-07 11:10:24 -07:00
Alexander Holliday 504d6157e5 Merge pull request #3473 from bluewave-labs/fix/actions
fix: disable poeditor workflow
2026-04-07 09:58:41 -07:00
Alex Holliday 628bca3e29 disable poeditor workflow 2026-04-07 09:56:11 -07:00
Alexander Holliday 2c76484bad Merge pull request #3471 from bluewave-labs/feat/zh-tw
feat: add remaining zh-tw strings
2026-04-07 09:53:58 -07:00
Alex Holliday 22498756cd add remaining zh-tw strings 2026-04-07 09:46:07 -07:00
Alexander Holliday 7c61ca490c Merge pull request #3461 from bluewave-labs/fix/i18n-locale-migration
fix: restructure and translate all i18n locale files
2026-04-03 20:46:13 -07:00
gorkem-bwl 96f9360280 style: format migration script with prettier 2026-04-03 23:43:34 -04:00
gorkem-bwl eb4a92c84f fix: restructure and translate all i18n locale files
All 15 non-English locale files had a structural mismatch with en.json:
- en.json used nested keys (common.buttons.cancel)
- Other locales used flat keys (cancel, DeleteAccountButton)
- Result: 0.3% effective translation coverage at runtime

## Changes

Migration script (client/scripts/migrate-locales.ts):
- Restructures all locale files to match en.json's nested key structure
- Recovers existing translations via safe key-matching strategies
- Backs up originals to locales/backup/

Translation of all 15 languages:
- ar (94.6%), cs (91.6%), de (89.4%), es (92.6%), fi (94.2%)
- fr (86.7%), ja (93.3%), pt-BR (91.0%), ru (93.0%), th (93.4%)
- tr (91.9%), uk (94.3%), vi (92.5%), zh-CN (94.0%), zh-TW (94.2%)

Keys intentionally left in English are technical terms (HTTP, CPU,
Docker, gRPC, etc.), placeholder examples, and brand names.

en.json was NOT modified.
2026-04-03 23:35:14 -04:00
gorkem-bwl c80720c8c9 fix: align server test suite with current source implementations
## Changes
- teamsProvider.test.ts: fix got mock wiring (use top-level mockPost
  with dynamic import so mock intercepts correctly), fix getTestMessage
  assertion, fix incident URL path
- superSimpleQueueHelper.test.ts: change default import to named import,
  update constructor from object to 16 positional params, rename
  getMonitorJob -> getHeartbeatJob, add all missing dependencies
- monitorService.test.ts: remove stale constructor params (db,
  stringService, papaparse, errorService), add missing params
  (geoChecksRepository, incidentsRepository), fix method names
  (findLatestChecksByMonitorIds -> findLatestByMonitorIds,
  findDateRangeChecksByMonitor -> findByDateRangeAndMonitorId),
  update assertions from checks -> recentChecks
- jest.config.ts: consolidate @/utils path mapping to resolve .ts files
- Extract shared createMockLogger to test/helpers/

Closes #3457
2026-04-03 21:56:14 -04:00
Alexander Holliday 42e82af9bb Merge pull request #3458 from bluewave-labs/feat/scheduler/1.9.1
feat: bump scheduler, consume scheduler events
2026-04-03 15:38:12 -07:00
Alex 7636d2d090 error -> warn on job fail, register listeners before starting scheduler 2026-04-03 15:36:37 -07:00
Alex Holliday 72728748f8 bump scheduler, consume scheduler events 2026-04-03 13:21:16 -07:00
Alexander Holliday 4222d39a8b Merge pull request #3456 from bluewave-labs/fix/job-queue
fix geocheck update logic
2026-04-01 11:04:50 -07:00
Alex Holliday fb69e29adf fix geocheck update logic 2026-04-01 11:00:17 -07:00
Alexander Holliday 0d90083e63 Merge pull request #3455 from mayssagl/develop
Fix outdated links and add Prerequisites section
2026-04-01 10:10:48 -07:00
Alexander Holliday 8ab049aa6c Merge pull request #3447 from bluewave-labs/feat/dlq
Feat(DLQ): Add DLQ data layer -> types, model, and repository
2026-03-27 12:35:31 -07:00
Alexander Holliday 69b6955ced Merge pull request #3439 from LeC-D/fix/telegram-notification-type
feat(notifications): add Telegram notification channel (#2753)
2026-03-27 12:04:01 -07:00
LeC-D 04dfff4ebd fix(notifications): remove redundant defaultValue prop and fix incident URL in telegram provider
- Remove redundant 'defaultValue' prop on Telegram chat ID Controller in create/index.tsx;
  the form already initialises via 'defaultValues: defaults' at the useForm level, making
  the per-Controller prop unnecessary and inconsistent with the reviewer's expectation.
- Fix 'View Incident' URL in telegram.ts: was pointing to the infrastructure details page
  (/infrastructure/:id) instead of the incidents page (/incidents/:id).
2026-03-27 14:19:27 -04:00
Aryaman Sharma 15b0deb0bd Merge branch 'develop' into feat/dlq 2026-03-27 23:24:45 +05:30
Br0wnHammer a895912b99 refactor(dlq): inline DLQItemDocumentBase into DLQItemDocument 2026-03-27 23:23:01 +05:30
Alexander Holliday 22d2626a93 Merge pull request #3441 from bluewave-labs/feat/timescaledb
feat: timescaledb
2026-03-27 10:40:17 -07:00
Br0wnHammer 214fc4899c feat(dlq): add MongoDLQRepository with retry polling and grouped count queries 2026-03-27 20:08:18 +05:30
Br0wnHammer e6088fe06f feat(dlq): add IDLQRepository interface for dead letter queue data access 2026-03-27 20:07:00 +05:30
Br0wnHammer 60b1d95b74 feat(dlq): add DLQItem Mongoose model with indexes for retry polling and cleanup 2026-03-27 20:06:12 +05:30
Br0wnHammer 94ca9896db feat(dlq): add DLQItem type definitions for dead letter queue 2026-03-27 20:05:03 +05:30
LeC-D 13a7ae82e3 fix(notifications): remove type casts with exhaustive type narrowing in useNotificationForm
Replace the loose NotificationDefaults intermediate type and 'as NotificationFormData'
cast with an exhaustive buildDefaults() helper that narrows each branch of the
discriminated union explicitly. TypeScript now infers the return type correctly from
the discriminated union without any casts, satisfying strong typing for all channels
(email, slack, discord, webhook, pager_duty, teams, matrix, telegram).
2026-03-26 14:07:50 -04:00
Alex Holliday de3af64254 migrations, gitignore 2026-03-26 10:57:20 -07:00
Alex Holliday 317d531775 remove comments 2026-03-26 10:44:59 -07:00
Alex Holliday 2b2db2df6f set default all for incident validation 2026-03-26 10:33:19 -07:00
Alex Holliday c558c4b635 use ca tables 2026-03-26 10:30:15 -07:00
Alex Holliday be29cce788 db types 2026-03-26 09:47:10 -07:00
Alex Holliday e87879970a fix TS errors 2026-03-26 09:30:20 -07:00
Leandre Chamberland-Dozois 4c94a7c21e fix: resolve TypeScript build errors in notification form types 2026-03-25 19:13:37 -04:00
LeC-D 40968c370b refactor(notifications): hoist default values, remove else block in useNotificationForm
Per @ajhollid review: define defaults up-front with a NotificationDefaults type
and eliminate the trailing else branch entirely. The if/else-if blocks only
override when the type is matrix or telegram.
2026-03-25 18:06:26 -04:00
LeC-DandClaude Opus 4.6 a864bc0c05 fix: run format and fix build errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:52:48 -04:00
Alex Holliday 62e86434c2 checks repo, barrel export 2026-03-25 13:52:18 -07:00
Alex Holliday 07957d3a1f geo-checks repo 2026-03-25 13:52:10 -07:00
Alex Holliday bc088c3589 add incidents repo, rename old repo 2026-03-25 13:51:57 -07:00
Alex Holliday 5ea86d8e71 add inivtes repo 2026-03-25 13:51:37 -07:00
Alex Holliday 0944dba386 add maintenance-windows repo 2026-03-25 13:51:23 -07:00
Alex Holliday 86acc0c30a add monitor-stats repo 2026-03-25 13:51:12 -07:00
Alex Holliday dcdde845db monitors rep 2026-03-25 13:51:03 -07:00
Alex Holliday 3039fb9b69 nottificaitons repo 2026-03-25 13:50:46 -07:00
Alex Holliday 729600a86f recovery tokens repo 2026-03-25 13:50:33 -07:00
Alex Holliday 51853cb360 add settings repo 2026-03-25 13:50:22 -07:00
Alex Holliday f7db732d67 add status-pages repo 2026-03-25 13:50:12 -07:00
Alex Holliday 9e686a2319 add user repo 2026-03-25 13:49:56 -07:00
Alex Holliday da6f03ba59 add teams repo 2026-03-25 13:49:46 -07:00
Alex Holliday 276b11603e throw error on missing continent 2026-03-25 13:49:13 -07:00
Alex Holliday f09f0630dc update error handler 2026-03-25 13:48:50 -07:00
Alex Holliday 1defa52ae8 update config 2026-03-25 13:48:36 -07:00
Alex Holliday f43b7584a6 add PG dependencies 2026-03-25 13:47:56 -07:00
Alex Holliday ee74584699 Add method to interface 2026-03-25 13:47:36 -07:00
Alex Holliday 2422fcc83b update gitignore 2026-03-25 13:47:14 -07:00
Alex Holliday 0ecc2e21e5 Implemement TimescaleDB class 2026-03-25 13:47:01 -07:00
LeC-D fe242b1977 fix: replace nested ternaries and remove type casts per review
- useNotificationForm: replace nested ternary with if/else if/else block
- TelegramProvider: type error as unknown and access properties safely
  via instanceof checks instead of casting to HTTPError
2026-03-25 14:03:49 -04:00
LeC-D d1f1d31ea2 fix(i18n): revert en.json to upstream format, keep only telegram keys
The previous commit reformatted the entire en.json (spaces → kept as-is
but accumulated unrelated upstream changes), creating a 1.3k line diff.

Restore the file to match upstream/develop exactly and add only the
Telegram-specific i18n keys required by this PR.
2026-03-24 17:22:34 -04:00
Léo eacbb227c3 feat(notifications): add Telegram notification channel (#2753)
Add Telegram as a new notification type, allowing users to receive
monitor alerts via a Telegram bot.

Configuration requires:
- Bot token (obtained from @BotFather)
- Chat ID (user, group, or channel)

Changes:
- TelegramProvider: sends HTML-formatted messages via Telegram Bot API
- NotificationsService: route telegram type to TelegramProvider
- Notification model/types: add 'telegram' to NotificationChannel enum
- Server validation: telegram schema (accessToken + address/chat_id)
- Client Types/Validation: telegram schema with accessToken + address
- useNotificationForm: handle telegram defaults
- Notifications create page: render Telegram config UI
- en.json: add telegram i18n keys
2026-03-24 10:15:46 -04:00
Mayssaaa gloulou ec129e3604 Fix outdated links and add Prerequisites section 2026-03-23 13:49:08 +01:00
155 changed files with 39684 additions and 16831 deletions

No files matched your search

+105 -105
View File
@@ -1,120 +1,120 @@
name: POEditor Translation Synchronization
# name: POEditor Translation Synchronization
on:
# For manual triggering
workflow_dispatch:
inputs:
languages:
description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
required: false
default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
format:
description: "Export format (key_value_json or json)"
required: false
default: "key_value_json"
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
permissions:
contents: write
pull-requests: write
# permissions:
# contents: write
# pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1"
}
}
EOF
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
- name: Download translations from POEditor
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
run: |
mkdir -p temp
node .github/scripts/download-translations.js
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
- name: Verify translation files
run: |
echo "Verifying translation files..."
for file in temp/*.json; do
echo "Checking $file"
if [ ! -s "$file" ]; then
echo "Error: $file is empty or does not exist"
exit 1
fi
# Validate JSON format
cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
done
echo "All translation files are valid"
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
- name: Copy translations to project
run: |
mkdir -p client/src/locales
cp -r temp/* client/src/locales/
echo "Translation files copied to client/src/locales/"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
- name: Format client code
run: |
cd client
npm ci
npm run format
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "feat: translations updated from POEditor"
title: "🌐 Updated Translations from POEditor"
body: |
This PR contains the latest translations from POEditor.
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
📅 Update Date: ${{ steps.date.outputs.date }}
🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
⚠️ Please review the translations and approve the PR if everything looks correct.
branch: translation-update-${{ github.run_number }}
delete-branch: true
base: develop
add-paths: |
client/src/locales/*.json
committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
author: GitHub Action <github-actions[bot]@users.noreply.github.com>
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+3 -1
View File
@@ -3,8 +3,10 @@
.VSCodeCounter
*.sh
mongo
timescaledb
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
docs/frontend
docs/timescale
+7 -9
View File
@@ -29,7 +29,6 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [📦 Demo](#demo)
- [🔗 User's guide](#users-guide)
- [🛠️ Installation](#installation)
- [🏁 Translations](#translations)
- [🚀 Performance](#performance)
- [💚 Questions & Ideas](#questions--ideas)
- [🧩 Features](#features)
@@ -48,11 +47,15 @@ You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.
Usage instructions can be found [here](https://checkmate.so/docs).
## Prerequisites
- [Docker](https://www.docker.com/) installed
- [Git](https://git-scm.com/) installed
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
@@ -60,11 +63,6 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
For more documentation, see the [docs directory](./docs/).
## Translations
If you would like to use Checkmate in your language, please [go to this page](https://poeditor.com/join/project/lRUoGZFCsJ) and register for the language you would like to translate Checkmate to.
## Performance
Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
@@ -80,7 +78,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -167,7 +165,7 @@ Here's how you can contribute:
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
</a>
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date)
## Our sponsors
+258
View File
@@ -0,0 +1,258 @@
/**
* Locale Migration Script
*
* Restructures all non-English locale files to match en.json's nested key structure.
* Recovers translations via safe key-matching strategies, uses English placeholders
* for unmatched keys.
*
* Usage: npx tsx client/scripts/migrate-locales.ts
*
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
*/
import fs from "fs";
import path from "path";
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Flatten a nested object into { "a.b.c": value } pairs */
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
} else {
result[fullPath] = String(value);
}
}
return result;
}
/** Set a value at a dot-separated path in a nested object */
function setNestedValue(
obj: Record<string, unknown>,
keyPath: string,
value: string
): void {
const parts = keyPath.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
// ── Build mapping indexes from en.json ───────────────────────────────────────
function buildIndexes(enFlat: Record<string, string>) {
const enKeys = Object.keys(enFlat);
// last segment -> en key paths
const lastSegIndex: Record<string, string[]> = {};
// last 2 segments -> en key paths
const suffixIndex: Record<string, string[]> = {};
for (const keyPath of enKeys) {
const parts = keyPath.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
lastSegIndex[lastSeg].push(keyPath);
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
suffixIndex[suffix].push(keyPath);
}
}
return { lastSegIndex, suffixIndex };
}
// ── Mapping logic ────────────────────────────────────────────────────────────
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
interface KeyMatch {
enKey: string;
value: string;
strategy: MatchStrategy;
}
function buildMigratedLocale(
enFlat: Record<string, string>,
langFlat: Record<string, string>,
indexes: ReturnType<typeof buildIndexes>
): { matches: KeyMatch[]; result: Record<string, unknown> } {
const { lastSegIndex, suffixIndex } = indexes;
const matches: KeyMatch[] = [];
const result: Record<string, unknown> = {};
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
for (const [langKey, langValue] of Object.entries(langFlat)) {
const parts = langKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
langBySuffix[suffix].push({ key: langKey, value: langValue });
}
}
for (const enKey of Object.keys(enFlat)) {
const enValue = enFlat[enKey];
const parts = enKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
// Strategy 1: Exact path match
if (langFlat[enKey] !== undefined) {
const value = langFlat[enKey];
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "exact" });
continue;
}
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
if (suffix) {
const enCandidates = suffixIndex[suffix];
const langCandidates = langBySuffix[suffix];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "suffix-2" });
continue;
}
}
// Strategy 3: Unique last-segment match
const enCandidates = lastSegIndex[lastSeg];
const langCandidates = langByLastSeg[lastSeg];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "unique-name" });
continue;
}
// No match — use English placeholder
setNestedValue(result, enKey, enValue);
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
}
return { matches, result };
}
// ── Main ─────────────────────────────────────────────────────────────────────
function main() {
// Load en.json
const enPath = path.join(LOCALES_DIR, "en.json");
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
const enFlat = flattenKeys(en);
const enKeyCount = Object.keys(enFlat).length;
const indexes = buildIndexes(enFlat);
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
// Find all non-English locale files
const localeFiles = fs
.readdirSync(LOCALES_DIR)
.filter(
(f: string) =>
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
);
if (localeFiles.length === 0) {
console.log("No locale files to migrate.");
return;
}
// Create backup directory
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
const report: Record<
string,
{
total: number;
exact: number;
suffix: number;
uniqueName: number;
placeholder: number;
}
> = {};
for (const file of localeFiles) {
const lang = file.replace(".json", "");
const filePath = path.join(LOCALES_DIR, file);
// Backup original
const backupPath = path.join(BACKUP_DIR, file);
fs.copyFileSync(filePath, backupPath);
// Load and flatten
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
const langFlat = flattenKeys(langData);
// Migrate
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
// Write migrated file
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
// Compute stats
const stats = {
total: enKeyCount,
exact: matches.filter((m) => m.strategy === "exact").length,
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
};
report[lang] = stats;
const translated = stats.exact + stats.suffix + stats.uniqueName;
const pct = ((translated / enKeyCount) * 100).toFixed(1);
console.log(
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
`placeholder: ${stats.placeholder}`
);
}
// Write report
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
const totalLangs = localeFiles.length;
console.log(
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
);
console.log(`Report written to ${REPORT_PATH}`);
}
main();
+63 -15
View File
@@ -1,28 +1,76 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
};
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
const defaults = buildDefaults(data);
return { schema: notificationSchema, defaults };
}, [data]);
};
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && (
{watchedType !== "matrix" && watchedType !== "telegram" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
<Controller
name="address"
control={control}
defaultValue={defaults.address}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -171,6 +171,49 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.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.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
@@ -180,7 +223,7 @@ const NotificationsCreatePage = () => {
<Controller
name="homeserverUrl"
control={control}
defaultValue={defaults.homeserverUrl}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -196,7 +239,7 @@ const NotificationsCreatePage = () => {
<Controller
name="roomId"
control={control}
defaultValue={defaults.roomId}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -212,7 +255,7 @@ const NotificationsCreatePage = () => {
<Controller
name="accessToken"
control={control}
defaultValue={defaults.accessToken}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
+1
View File
@@ -6,6 +6,7 @@ export const NotificationChannels = [
"pager_duty",
"matrix",
"teams",
"telegram",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+7
View File
@@ -50,6 +50,12 @@ const teamsSchema = baseSchema.extend({
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
const telegramSchema = baseSchema.extend({
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -58,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
pagerDutySchema,
matrixSchema,
teamsSchema,
telegramSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -951,6 +951,14 @@
"description": "Select the type of notification channel to create.",
"optionType": "Type",
"title": "Channel type"
},
"telegram": {
"title": "Telegram configuration",
"description": "Configure your Telegram bot to send notifications.",
"optionBotToken": "Bot token",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
}
},
"table": {
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1085
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1268 -801
View File
File diff suppressed because it is too large. Load diff
+1268 -801
View File
File diff suppressed because it is too large. Load diff
+1271 -1098
View File
File diff suppressed because it is too large. Load diff
+1299 -1088
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -9,8 +9,7 @@ const config: Config = {
},
moduleNameMapper: {
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
+267 -200
View File
@@ -37,10 +37,11 @@
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -62,12 +63,12 @@
"@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",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
@@ -799,40 +800,20 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz",
"integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==",
"version": "3.972.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
"integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.13.0",
"fast-xml-parser": "5.4.1",
"@smithy/types": "^4.13.1",
"fast-xml-parser": "5.5.8",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
@@ -873,6 +854,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1375,16 +1357,6 @@
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -1502,6 +1474,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1546,6 +1519,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3850,9 +3824,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
"version": "4.13.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -4337,6 +4311,7 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -4497,6 +4472,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -4522,6 +4498,18 @@
"@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",
@@ -4734,6 +4722,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -4942,9 +4931,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5347,6 +5336,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5884,9 +5874,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5925,6 +5915,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6051,40 +6042,6 @@
"node": ">= 0.8"
}
},
"node_modules/c8": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz",
"integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^7.0.1",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
@@ -6867,6 +6824,7 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssnano-preset-default": "^7.0.9",
"lilconfig": "^3.1.3"
@@ -7634,6 +7592,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7995,6 +7954,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -8127,9 +8087,9 @@
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"dev": true,
"funding": [
{
@@ -8137,7 +8097,31 @@
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.20.1",
@@ -8278,9 +8262,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -8727,9 +8711,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -8829,9 +8813,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
@@ -9639,6 +9623,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -10130,9 +10115,9 @@
}
},
"node_modules/jest-util/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10667,9 +10652,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -11211,9 +11196,9 @@
}
},
"node_modules/mjml-cli/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -12307,6 +12292,22 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz",
"integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -12343,9 +12344,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/path-type": {
@@ -12358,6 +12359,96 @@
"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",
@@ -12365,9 +12456,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -12509,6 +12600,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -12982,6 +13074,45 @@
"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",
@@ -14027,6 +14158,15 @@
"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",
@@ -14344,9 +14484,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"dev": true,
"funding": [
{
@@ -14373,25 +14513,12 @@
}
},
"node_modules/super-simple-scheduler": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.4.5.tgz",
"integrity": "sha512-xHR3a+pLywY7HtwjWj/+OaANdEwAieQhckC/i0s6OoHtVJQ4+VLO1VfkKUbxp+oSROGXcaZet8PTGONVdKNUUw==",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz",
"integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==",
"license": "MIT",
"dependencies": {
"uuid": "11.1.0",
"winston": "3.17.0"
}
},
"node_modules/super-simple-scheduler/node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"uuid": "11.1.0"
}
},
"node_modules/super-simple-scheduler/node_modules/uuid": {
@@ -14407,28 +14534,6 @@
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/super-simple-scheduler/node_modules/winston": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"logform": "^2.7.0",
"one-time": "^1.0.0",
"readable-stream": "^3.4.0",
"safe-stable-stringify": "^2.3.1",
"stack-trace": "0.0.x",
"triple-beam": "^1.3.0",
"winston-transport": "^4.9.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -14516,47 +14621,6 @@
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
"integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^9.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -14599,11 +14663,12 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14772,6 +14837,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14934,6 +15000,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14987,9 +15054,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -15657,9 +15724,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {
+7 -3
View File
@@ -5,7 +5,10 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"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",
@@ -52,10 +55,11 @@
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
@@ -77,12 +81,12 @@
"@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",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
"esm": "3.2.25",
+85 -17
View File
@@ -28,6 +28,7 @@ import {
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
TelegramProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -46,6 +47,7 @@ import {
IIncidentService,
INotificationMessageBuilder,
ISettingsService,
SettingsService,
EnvConfig,
} from "@/service/index.js";
@@ -92,9 +94,23 @@ import {
MongoInvitesRepository,
MongoRecoveryTokensRepository,
MongoNotificationsRepository,
MongoIncidentRepository,
MongoIncidentsRepository,
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
TimescaleMonitorsRepository,
TimescaleChecksRepository,
TimescaleGeoChecksRepository,
TimescaleMonitorStatsRepository,
TimescaleStatusPagesRepository,
TimescaleUsersRepository,
TimescaleInvitesRepository,
TimescaleRecoveryTokensRepository,
TimescaleNotificationsRepository,
TimescaleIncidentsRepository,
TimescaleTeamsRepository,
TimescaleMaintenanceWindowsRepository,
TimescaleSettingsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
@@ -110,6 +126,8 @@ 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 = {
settingsService: ISettingsService;
@@ -152,32 +170,81 @@ export const initializeServices = async ({
logger,
envSettings,
settingsService,
settingsRepository,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: ISettingsService;
settingsRepository: ISettingsRepository;
}): Promise<InitializedServices> => {
// Create DB
const db = new MongoDB(logger, envSettings);
const dbType = envSettings.dbType;
let db: IDb | null = null;
if (dbType === "mongodb") {
db = new MongoDB(logger, envSettings);
} else if (dbType === "timescaledb") {
db = new TimescaleDB(logger, envSettings);
}
if (!db) {
throw new AppError({ message: "Unsupported database type", status: 500 });
}
await db.connect();
let monitorsRepository: IMonitorsRepository;
let checksRepository: IChecksRepository;
let geoChecksRepository: IGeoChecksRepository;
let monitorStatsRepository: IMonitorStatsRepository;
let statusPagesRepository: IStatusPagesRepository;
let usersRepository: IUsersRepository;
let invitesRepository: IInvitesRepository;
let recoveryTokensRepository: IRecoveryTokensRepository;
let settingsRepository: ISettingsRepository;
let notificationsRepository: INotificationsRepository;
let incidentsRepository: IIncidentsRepository;
let teamsRepository: ITeamsRepository;
let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const geoChecksRepository = new MongoGeoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
const invitesRepository = new MongoInvitesRepository();
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
const notificationsRepository = new MongoNotificationsRepository();
const incidentsRepository = new MongoIncidentRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
if (dbType === "mongodb") {
monitorsRepository = new MongoMonitorsRepository();
checksRepository = new MongoChecksRepository(logger);
geoChecksRepository = new MongoGeoChecksRepository(logger);
monitorStatsRepository = new MongoMonitorStatsRepository();
statusPagesRepository = new MongoStatusPagesRepository();
usersRepository = new MongoUsersRepository();
invitesRepository = new MongoInvitesRepository();
recoveryTokensRepository = new MongoRecoveryTokensRepository();
settingsRepository = new MongoSettingsRepository();
notificationsRepository = new MongoNotificationsRepository();
incidentsRepository = new MongoIncidentsRepository();
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);
}
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
// Network providers
const pingProvider = new PingProvider(ping);
@@ -230,6 +297,7 @@ export const initializeServices = async ({
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -241,6 +309,7 @@ export const initializeServices = async ({
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -295,7 +364,6 @@ export const initializeServices = async ({
});
const monitorService = new MonitorService({
jobQueue: superSimpleQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -26,16 +26,6 @@ class GeoCheckController implements IGeoCheckController {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
if (!validatedQuery.continent || validatedQuery.continent.length === 0) {
throw new AppError({
message: "At least one continent must be specified",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
details: { monitorId: validatedParams.monitorId },
status: 400,
});
}
const teamId = requireTeamId(req.user?.teamId);
const result = await this.geoChecksService.getGeoChecksByMonitor({
+3
View File
@@ -1,4 +1,7 @@
import { Pool } from "pg";
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPool(): Pool | null;
}
+5
View File
@@ -5,6 +5,7 @@ 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;
@@ -74,6 +75,10 @@ class MongoDB implements IDb {
});
}
};
getPool = () => {
return null;
};
}
export default MongoDB;
+105
View File
@@ -0,0 +1,105 @@
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,50 @@
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;
`);
};
@@ -0,0 +1,18 @@
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;`);
};
@@ -0,0 +1,30 @@
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;`);
};
@@ -0,0 +1,69 @@
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;`);
};
@@ -0,0 +1,30 @@
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;`);
};
@@ -0,0 +1,18 @@
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;`);
};
@@ -0,0 +1,34 @@
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;`);
};
@@ -0,0 +1,25 @@
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;`);
};
@@ -0,0 +1,105 @@
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;`);
};
@@ -0,0 +1,37 @@
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;`);
};
@@ -0,0 +1,34 @@
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;`);
};
@@ -0,0 +1,25 @@
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;`);
};
@@ -0,0 +1,29 @@
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;`);
};
@@ -0,0 +1,46 @@
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;`);
};
@@ -0,0 +1,22 @@
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;`);
};
@@ -0,0 +1,20 @@
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;`);
};
@@ -0,0 +1,29 @@
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;`);
};
@@ -0,0 +1,51 @@
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;
`);
};
@@ -0,0 +1,62 @@
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();
`);
};
@@ -0,0 +1,127 @@
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;
`);
};
@@ -0,0 +1,135 @@
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);`);
};
@@ -0,0 +1,148 @@
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 };
+70
View File
@@ -0,0 +1,70 @@
import { Schema, model, type Types } from "mongoose";
import { DLQItemTypes, DLQItemStatuses, type DLQItem } from "@/types/dlqItem.js";
export interface DLQItemDocument extends Omit<DLQItem, "id" | "monitorId" | "teamId" | "nextRetryAt" | "createdAt" | "updatedAt"> {
_id: Types.ObjectId;
monitorId: Types.ObjectId;
teamId: Types.ObjectId;
nextRetryAt: Date;
createdAt: Date;
updatedAt: Date;
}
const DLQItemSchema = new Schema<DLQItemDocument>(
{
type: {
type: String,
enum: DLQItemTypes,
required: true,
},
status: {
type: String,
enum: DLQItemStatuses,
default: "pending",
},
payload: {
type: Schema.Types.Mixed,
required: true,
},
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
required: true,
immutable: true,
index: true,
},
teamId: {
type: Schema.Types.ObjectId,
ref: "Team",
required: true,
immutable: true,
index: true,
},
retryCount: {
type: Number,
default: 0,
},
maxRetries: {
type: Number,
default: 5,
},
lastError: {
type: String,
default: "",
},
nextRetryAt: {
type: Date,
required: true,
},
},
{ timestamps: true }
);
DLQItemSchema.index({ status: 1, nextRetryAt: 1 });
DLQItemSchema.index({ teamId: 1, status: 1 });
DLQItemSchema.index({ createdAt: 1 });
const DLQItemModel = model<DLQItemDocument>("DLQItem", DLQItemSchema);
export { DLQItemModel };
export default DLQItemModel;
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
required: true,
},
notificationName: {
+3
View File
@@ -36,3 +36,6 @@ export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow
export * from "@/db/models/GeoCheck.js";
export { default as GeoCheckModel } from "@/db/models/GeoCheck.js";
export * from "@/db/models/DLQItem.js";
export { default as DLQItemModel } from "@/db/models/DLQItem.js";
+6 -12
View File
@@ -1,16 +1,14 @@
import { initializeServices } from "./config/services.js";
import { initializeControllers } from "./config/controllers.js";
import { createApp } from "./app.js";
import { initShutdownListener } from "./shutdown.js";
import { validateEnv } from "./validation/envValidation.js";
import { initShutdownListener } from "@/shutdown.js";
import { validateEnv } from "@/validation/envValidation.js";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs";
import { runMigrations } from "./db/migration/index.js";
import Logger, { ILogger } from "@/utils/logger.js";
import { SettingsService } from "@/service/index.js";
import { MongoSettingsRepository } from "./repositories/index.js";
const SERVICE_NAME = "Server";
let logger: ILogger;
@@ -25,19 +23,15 @@ const startApp = async () => {
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
const frontendPath = path.join(__dirname, "..", "public");
// Create services
const settingsRepository = new MongoSettingsRepository();
const settingsService = new SettingsService(settingsRepository, env);
// Create settings service (env only — DB repository injected after connect)
const settingsService = new SettingsService(env);
const envSettings = settingsService.loadSettings();
// Create logger
logger = new Logger({ envSettings });
// Initialize services
const services = await initializeServices({ logger, envSettings, settingsService, settingsRepository });
await runMigrations(logger);
// Initialize services (connects DB, creates repositories, injects settingsRepository)
const services = await initializeServices({ logger, envSettings, settingsService });
// Initialize controllers
const controllers = initializeControllers(services);
+2 -2
View File
@@ -4,14 +4,14 @@ import { AppError } from "@/utils/AppError.js";
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
const status = error instanceof AppError ? error.status || 500 : 500;
const message = error instanceof AppError ? error.message : "Server error";
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
const service = error instanceof AppError ? error.service : "unknownService";
const method = error instanceof AppError ? error.method : "unknownMethod";
logger.error({
message: message,
service: service,
method: method,
stack: error instanceof AppError ? error.stack : undefined,
stack: error instanceof Error ? error.stack : undefined,
details: error instanceof AppError ? error.details : undefined,
});
res.status(status).json({
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
import type { DLQItem, DLQItemType, DLQItemStatus } from "@/types/index.js";
export interface DLQQueryFilters {
type?: DLQItemType;
status?: DLQItemStatus;
page: number;
rowsPerPage: number;
}
export interface DLQStatusCount {
status: DLQItemStatus;
type: DLQItemType;
count: number;
}
export interface IDLQRepository {
// create
create(item: Partial<DLQItem>): Promise<DLQItem>;
// fetch
findById(id: string): Promise<DLQItem | null>;
findByTeamId(teamId: string, filters: DLQQueryFilters): Promise<DLQItem[]>;
findRetryable(limit: number): Promise<DLQItem[]>;
countByTeamId(teamId: string): Promise<number>;
countByTeamIdGrouped(teamId: string): Promise<DLQStatusCount[]>;
// update
updateById(id: string, patch: Partial<DLQItem>): Promise<DLQItem>;
// delete
deleteById(id: string, teamId: string): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
}
@@ -0,0 +1,139 @@
import { DLQItemModel } from "@/db/models/index.js";
import type { DLQItemDocument } from "@/db/models/DLQItem.js";
import type { DLQItem } from "@/types/index.js";
import type { IDLQRepository, DLQQueryFilters, DLQStatusCount } from "@/repositories/dlq/IDLQRepository.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
class MongoDLQRepository implements IDLQRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
if (!value) {
return "";
}
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
};
private toDateString = (value?: Date | string | null): string => {
if (!value) {
return new Date(0).toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
protected toEntity = (doc: DLQItemDocument): DLQItem => {
return {
id: this.toStringId(doc._id),
type: doc.type,
status: doc.status,
payload: doc.payload,
monitorId: this.toStringId(doc.monitorId),
teamId: this.toStringId(doc.teamId),
retryCount: doc.retryCount,
maxRetries: doc.maxRetries,
lastError: doc.lastError,
nextRetryAt: this.toDateString(doc.nextRetryAt),
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
};
protected mapDocuments = (documents: DLQItemDocument[] | DLQItemDocument | null): DLQItem[] => {
if (!documents) {
return [];
}
if (Array.isArray(documents)) {
return documents.map((doc) => this.toEntity(doc));
}
return [this.toEntity(documents)];
};
async create(item: Partial<DLQItem>): Promise<DLQItem> {
const newItem = await DLQItemModel.create(item);
return this.toEntity(newItem);
}
findById = async (id: string): Promise<DLQItem | null> => {
const item = await DLQItemModel.findById(new mongoose.Types.ObjectId(id));
return item ? this.toEntity(item) : null;
};
findByTeamId = async (teamId: string, filters: DLQQueryFilters): Promise<DLQItem[]> => {
const query: Record<string, unknown> = {
teamId: new mongoose.Types.ObjectId(teamId),
};
if (filters.type) {
query.type = filters.type;
}
if (filters.status) {
query.status = filters.status;
}
const items = await DLQItemModel.find(query)
.sort({ createdAt: -1 })
.skip(filters.page * filters.rowsPerPage)
.limit(filters.rowsPerPage);
return this.mapDocuments(items);
};
findRetryable = async (limit: number): Promise<DLQItem[]> => {
const items = await DLQItemModel.find({
status: { $in: ["pending", "retrying"] },
nextRetryAt: { $lte: new Date() },
})
.sort({ nextRetryAt: 1 })
.limit(limit);
return this.mapDocuments(items);
};
countByTeamId = async (teamId: string): Promise<number> => {
return DLQItemModel.countDocuments({
teamId: new mongoose.Types.ObjectId(teamId),
});
};
countByTeamIdGrouped = async (teamId: string): Promise<DLQStatusCount[]> => {
const results = await DLQItemModel.aggregate([
{ $match: { teamId: new mongoose.Types.ObjectId(teamId) } },
{
$group: {
_id: { status: "$status", type: "$type" },
count: { $sum: 1 },
},
},
]);
return results.map((r) => ({
status: r._id.status,
type: r._id.type,
count: r.count,
}));
};
updateById = async (id: string, patch: Partial<DLQItem>): Promise<DLQItem> => {
const updated = await DLQItemModel.findOneAndUpdate(
{ _id: new mongoose.Types.ObjectId(id) },
{ $set: { ...patch } },
{ new: true, runValidators: true }
);
if (!updated) {
throw new AppError({ message: `DLQ item with id ${id} not found`, status: 404 });
}
return this.toEntity(updated);
};
deleteById = async (id: string, teamId: string): Promise<number> => {
const result = await DLQItemModel.deleteOne({
_id: new mongoose.Types.ObjectId(id),
teamId: new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
};
deleteOlderThan = async (date: Date): Promise<number> => {
const result = await DLQItemModel.deleteMany({
createdAt: { $lt: date },
});
return result.deletedCount || 0;
};
}
export default MongoDLQRepository;
@@ -0,0 +1,274 @@
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(),
});
}
@@ -5,7 +5,7 @@ import type { IIncidentsRepository } from "@/repositories/index.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
class MongoIncidentRepository implements IIncidentsRepository {
class MongoIncidentsRepository implements IIncidentsRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
if (!value) {
return "";
@@ -288,4 +288,4 @@ class MongoIncidentRepository implements IIncidentsRepository {
return result.deletedCount ?? 0;
};
}
export default MongoIncidentRepository;
export default MongoIncidentsRepository;
@@ -0,0 +1,294 @@
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(),
});
}
+17 -1
View File
@@ -1,38 +1,54 @@
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 MongoIncidentRepository } from "@/repositories/incidents/MongoIncidentRepository.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";
@@ -0,0 +1,77 @@
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(),
});
}
@@ -0,0 +1,197 @@
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(),
});
}
@@ -0,0 +1,140 @@
import type { Pool } from "pg";
import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js";
import type { MonitorStats } from "@/types/monitorStats.js";
import { AppError } from "@/utils/AppError.js";
interface MonitorStatsRow {
id: string;
monitor_id: string;
avg_response_time: number;
max_response_time: number;
total_checks: number;
total_up_checks: number;
total_down_checks: number;
uptime_percentage: number;
last_check_timestamp: Date | null;
last_response_time: number;
time_of_last_failure: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure,
created_at, updated_at`;
export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository {
constructor(private pool: Pool) {}
create = async (data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(
`INSERT INTO monitor_stats (monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING ${COLUMNS}`,
[
data.monitorId,
data.avgResponseTime,
data.maxResponseTime,
data.totalChecks,
data.totalUpChecks,
data.totalDownChecks,
data.uptimePercentage,
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
data.lastResponseTime,
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create monitor stats", status: 500 });
}
return this.toEntity(row);
};
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(`SELECT ${COLUMNS} FROM monitor_stats WHERE monitor_id = $1`, [monitorId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(
`UPDATE monitor_stats SET
avg_response_time = $2,
max_response_time = $3,
total_checks = $4,
total_up_checks = $5,
total_down_checks = $6,
uptime_percentage = $7,
last_check_timestamp = $8,
last_response_time = $9,
time_of_last_failure = $10,
updated_at = NOW()
WHERE monitor_id = $1
RETURNING ${COLUMNS}`,
[
monitorId,
data.avgResponseTime,
data.maxResponseTime,
data.totalChecks,
data.totalUpChecks,
data.totalDownChecks,
data.uptimePercentage,
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
data.lastResponseTime,
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
deleteByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(`DELETE FROM monitor_stats WHERE monitor_id = $1 RETURNING ${COLUMNS}`, [monitorId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
deleteByMonitorIds = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
return 0;
}
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = ANY($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM monitor_stats`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private toEntity = (row: MonitorStatsRow): MonitorStats => ({
id: row.id,
monitorId: row.monitor_id,
avgResponseTime: row.avg_response_time,
maxResponseTime: row.max_response_time,
totalChecks: Number(row.total_checks),
totalUpChecks: Number(row.total_up_checks),
totalDownChecks: Number(row.total_down_checks),
uptimePercentage: row.uptime_percentage,
lastCheckTimestamp: row.last_check_timestamp ? row.last_check_timestamp.getTime() : 0,
lastResponseTime: row.last_response_time,
timeOfLastFailure: row.time_of_last_failure ? row.time_of_last_failure.getTime() : undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,140 @@
import type { Pool } from "pg";
import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js";
import type { Notification, NotificationChannel } from "@/types/notification.js";
import { AppError } from "@/utils/AppError.js";
interface NotificationRow {
id: string;
user_id: string;
team_id: string;
type: NotificationChannel;
notification_name: string;
address: string | null;
phone: string | null;
homeserver_url: string | null;
room_id: string | null;
access_token: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone,
homeserver_url, room_id, access_token, created_at, updated_at`;
export class TimescaleNotificationsRepository implements INotificationsRepository {
constructor(private pool: Pool) {}
create = async (data: Partial<Notification>): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(
`INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING ${COLUMNS}`,
[
data.userId,
data.teamId,
data.type,
data.notificationName,
data.address ?? null,
data.phone ?? null,
data.homeserverUrl ?? null,
data.roomId ?? null,
data.accessToken ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create notification", status: 500 });
}
return this.toEntity(row);
};
findById = async (id: string, teamId: string): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = $1 AND team_id = $2`, [id, teamId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found", status: 404 });
}
return this.toEntity(row);
};
findNotificationsByIds = async (ids: string[]): Promise<Notification[]> => {
if (!ids.length) {
return [];
}
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]);
return result.rows.map(this.toEntity);
};
findByTeamId = async (teamId: string): Promise<Notification[]> => {
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, teamId: string, patch: Partial<Notification>): Promise<Notification> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof Notification, string][] = [
["type", "type"],
["notificationName", "notification_name"],
["address", "address"],
["phone", "phone"],
["homeserverUrl", "homeserver_url"],
["roomId", "room_id"],
["accessToken", "access_token"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(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<NotificationRow>(
`UPDATE notifications SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found or could not be updated", status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string, teamId: string): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(`DELETE FROM notifications WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, [
id,
teamId,
]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found or could not be deleted", status: 404 });
}
return this.toEntity(row);
};
private toEntity = (row: NotificationRow): Notification => ({
id: row.id,
userId: row.user_id,
teamId: row.team_id,
type: row.type,
notificationName: row.notification_name,
address: row.address ?? undefined,
phone: row.phone ?? undefined,
homeserverUrl: row.homeserver_url ?? undefined,
roomId: row.room_id ?? undefined,
accessToken: row.access_token ?? undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -0,0 +1,59 @@
import type { Pool } from "pg";
import crypto from "crypto";
import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js";
import type { RecoveryToken } from "@/types/recoveryToken.js";
import { AppError } from "@/utils/AppError.js";
interface RecoveryTokenRow {
id: string;
email: string;
token: string;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, email, token, expiry, created_at, updated_at`;
export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository {
constructor(private pool: Pool) {}
create = async (email: string): Promise<RecoveryToken> => {
const token = crypto.randomBytes(32).toString("hex");
const result = await this.pool.query<RecoveryTokenRow>(
`INSERT INTO recovery_tokens (email, token)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET token = $2, updated_at = NOW()
RETURNING ${COLUMNS}`,
[email, token]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create recovery token", status: 500 });
}
return this.toEntity(row);
};
findByToken = async (token: string): Promise<RecoveryToken> => {
const result = await this.pool.query<RecoveryTokenRow>(`SELECT ${COLUMNS} FROM recovery_tokens WHERE token = $1`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Recovery token not found", status: 404 });
}
return this.toEntity(row);
};
deleteManyByEmail = async (email: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM recovery_tokens WHERE email = $1`, [email]);
return result.rowCount ?? 0;
};
private toEntity = (row: RecoveryTokenRow): RecoveryToken => ({
id: row.id,
email: row.email,
token: row.token,
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -0,0 +1,217 @@
import type { Pool } from "pg";
import type { ISettingsRepository } from "./ISettingsRepository.js";
import type { Settings, SettingsUpdate } from "@/types/settings.js";
interface SettingsRow {
id: string;
check_ttl: number;
language: string | null;
jwt_secret: string | null;
pagespeed_api_key: string | null;
system_email_host: string | null;
system_email_port: number | null;
system_email_address: string | null;
system_email_password: string | null;
system_email_user: string | null;
system_email_connection_host: string | null;
system_email_tls_servername: string | null;
system_email_secure: boolean | null;
system_email_pool: boolean | null;
system_email_ignore_tls: boolean | null;
system_email_require_tls: boolean | null;
system_email_reject_unauthorized: boolean | null;
show_url: boolean | null;
version: string | null;
threshold_cpu_usage: number | null;
threshold_memory_usage: number | null;
threshold_disk_usage: number | null;
threshold_temperature: number | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
created_at, updated_at`;
export class TimescaleSettingsRepository implements ISettingsRepository {
constructor(private pool: Pool) {}
create = async (settings: Partial<Settings>): Promise<Settings> => {
const result = await this.pool.query<SettingsRow>(
`INSERT INTO app_settings (check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING ${COLUMNS}`,
[
settings.checkTTL ?? 30,
settings.language ?? null,
settings.jwtSecret ?? null,
settings.pagespeedApiKey ?? null,
settings.systemEmailHost ?? null,
settings.systemEmailPort ?? null,
settings.systemEmailAddress ?? null,
settings.systemEmailPassword ?? null,
settings.systemEmailUser ?? null,
settings.systemEmailConnectionHost ?? null,
settings.systemEmailTLSServername ?? null,
settings.systemEmailSecure ?? false,
settings.systemEmailPool ?? false,
settings.systemEmailIgnoreTLS ?? false,
settings.systemEmailRequireTLS ?? false,
settings.systemEmailRejectUnauthorized ?? true,
settings.showURL ?? false,
settings.version ?? 1,
settings.globalThresholds?.cpu ?? null,
settings.globalThresholds?.memory ?? null,
settings.globalThresholds?.disk ?? null,
settings.globalThresholds?.temperature ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new Error("Failed to create app settings");
}
return this.toEntity(row);
};
findSingleton = async (): Promise<Settings | null> => {
const result = await this.pool.query<SettingsRow>(`SELECT ${COLUMNS} FROM app_settings LIMIT 1`);
const row = result.rows[0];
if (!row) {
return null;
}
return this.toEntity(row);
};
update = async (settings: SettingsUpdate): Promise<Settings> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof SettingsUpdate, string][] = [
["checkTTL", "check_ttl"],
["language", "language"],
["jwtSecret", "jwt_secret"],
["pagespeedApiKey", "pagespeed_api_key"],
["systemEmailHost", "system_email_host"],
["systemEmailPort", "system_email_port"],
["systemEmailAddress", "system_email_address"],
["systemEmailPassword", "system_email_password"],
["systemEmailUser", "system_email_user"],
["systemEmailConnectionHost", "system_email_connection_host"],
["systemEmailTLSServername", "system_email_tls_servername"],
["systemEmailSecure", "system_email_secure"],
["systemEmailPool", "system_email_pool"],
["systemEmailIgnoreTLS", "system_email_ignore_tls"],
["systemEmailRequireTLS", "system_email_require_tls"],
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
["showURL", "show_url"],
["version", "version"],
];
for (const [key, column] of fieldMap) {
if (key in settings) {
sets.push(`${column} = $${paramIndex++}`);
// null means unset the field
values.push(settings[key] ?? null);
}
}
// Handle globalThresholds
if ("globalThresholds" in settings) {
const thresholds = settings.globalThresholds;
if (thresholds === null || thresholds === undefined) {
sets.push(`threshold_cpu_usage = NULL`);
sets.push(`threshold_memory_usage = NULL`);
sets.push(`threshold_disk_usage = NULL`);
sets.push(`threshold_temperature = NULL`);
} else {
if ("cpu" in thresholds) {
sets.push(`threshold_cpu_usage = $${paramIndex++}`);
values.push(thresholds.cpu ?? null);
}
if ("memory" in thresholds) {
sets.push(`threshold_memory_usage = $${paramIndex++}`);
values.push(thresholds.memory ?? null);
}
if ("disk" in thresholds) {
sets.push(`threshold_disk_usage = $${paramIndex++}`);
values.push(thresholds.disk ?? null);
}
if ("temperature" in thresholds) {
sets.push(`threshold_temperature = $${paramIndex++}`);
values.push(thresholds.temperature ?? null);
}
}
}
if (sets.length === 0) {
const existing = await this.findSingleton();
if (!existing) {
throw new Error("App settings not found");
}
return existing;
}
sets.push(`updated_at = NOW()`);
// Upsert: update the single row, or insert if none exists
const result = await this.pool.query<SettingsRow>(`UPDATE app_settings SET ${sets.join(", ")} RETURNING ${COLUMNS}`, values);
const row = result.rows[0];
if (!row) {
// No row existed, create one
return this.create(settings as Partial<Settings>);
}
return this.toEntity(row);
};
deleteLegacy = async (): Promise<boolean> => {
// No legacy rows in TimescaleDB — singleton is enforced by trigger
const result = await this.pool.query(`DELETE FROM app_settings WHERE version IS NULL`);
return (result.rowCount ?? 0) > 0;
};
private toEntity = (row: SettingsRow): Settings => ({
id: row.id,
checkTTL: row.check_ttl,
language: row.language ?? "",
jwtSecret: row.jwt_secret ?? undefined,
pagespeedApiKey: row.pagespeed_api_key ?? undefined,
systemEmailHost: row.system_email_host ?? undefined,
systemEmailPort: row.system_email_port ?? undefined,
systemEmailAddress: row.system_email_address ?? undefined,
systemEmailPassword: row.system_email_password ?? undefined,
systemEmailUser: row.system_email_user ?? undefined,
systemEmailConnectionHost: row.system_email_connection_host ?? undefined,
systemEmailTLSServername: row.system_email_tls_servername ?? undefined,
systemEmailSecure: row.system_email_secure ?? false,
systemEmailPool: row.system_email_pool ?? false,
systemEmailIgnoreTLS: row.system_email_ignore_tls ?? false,
systemEmailRequireTLS: row.system_email_require_tls ?? false,
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
showURL: row.show_url ?? false,
singleton: true,
version: Number(row.version ?? 1),
globalThresholds:
row.threshold_cpu_usage !== null ||
row.threshold_memory_usage !== null ||
row.threshold_disk_usage !== null ||
row.threshold_temperature !== null
? {
cpu: row.threshold_cpu_usage ?? undefined,
memory: row.threshold_memory_usage ?? undefined,
disk: row.threshold_disk_usage ?? undefined,
temperature: row.threshold_temperature ?? undefined,
}
: undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -0,0 +1,260 @@
import type { Pool } from "pg";
import type { IStatusPagesRepository } from "./IStatusPagesRepository.js";
import type { StatusPage, StatusPageType } from "@/types/statusPage.js";
import { AppError } from "@/utils/AppError.js";
const parsePostgresArray = (value: unknown): StatusPageType[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as StatusPageType[];
};
interface StatusPageRow {
id: string;
user_id: string;
team_id: string;
types: StatusPageType[];
company_name: string;
url: string;
timezone: string | null;
color: string;
logo_data: Buffer | null;
logo_content_type: string | null;
is_published: boolean;
show_charts: boolean;
show_uptime_percentage: boolean;
show_admin_login_link: boolean;
show_infrastructure: boolean;
custom_css: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`;
export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
constructor(private pool: Pool) {}
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const result = await this.pool.query<StatusPageRow>(
`INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING ${COLUMNS}`,
[
userId,
teamId,
data.type ?? ["uptime"],
data.companyName ?? "",
data.url ?? "",
data.timezone ?? null,
data.color ?? "#4169E1",
image?.buffer ?? null,
image?.mimetype ?? null,
data.isPublished ?? false,
data.showCharts ?? false,
data.showUptimePercentage ?? false,
data.showAdminLoginLink ?? false,
data.showInfrastructure ?? false,
data.customCSS ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create status page", status: 500 });
}
// Insert monitor associations
if (data.monitors?.length) {
for (const [i, monitorId] of data.monitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[row.id, monitorId, i]
);
}
}
if (data.subMonitors?.length) {
for (const [i, monitorId] of data.subMonitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[row.id, monitorId, i]
);
}
}
return this.loadEntity(row);
};
findByUrl = async (url: string): Promise<StatusPage> => {
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE url = $1`, [url]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.loadEntity(row);
};
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE team_id = $1`, [teamId]);
const pages: StatusPage[] = [];
for (const row of result.rows) {
pages.push(await this.loadEntity(row));
}
return pages;
};
updateById = async (
id: string,
teamId: string,
image: Express.Multer.File | undefined,
patch: Partial<StatusPage> & { removeLogo?: string }
): Promise<StatusPage> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof StatusPage, string][] = [
["type", "types"],
["companyName", "company_name"],
["url", "url"],
["timezone", "timezone"],
["color", "color"],
["isPublished", "is_published"],
["showCharts", "show_charts"],
["showUptimePercentage", "show_uptime_percentage"],
["showAdminLoginLink", "show_admin_login_link"],
["showInfrastructure", "show_infrastructure"],
["customCSS", "custom_css"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(patch[key]);
}
}
if (image) {
sets.push(`logo_data = $${paramIndex++}`);
values.push(image.buffer);
sets.push(`logo_content_type = $${paramIndex++}`);
values.push(image.mimetype);
} else if (patch.removeLogo === "true") {
sets.push(`logo_data = NULL`);
sets.push(`logo_content_type = NULL`);
}
if (sets.length > 0) {
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<StatusPageRow>(
`UPDATE status_pages SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
}
// Update monitor associations if provided
if (patch.monitors !== undefined) {
await this.pool.query(`DELETE FROM status_page_monitors WHERE status_page_id = $1`, [id]);
for (const [i, monitorId] of patch.monitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[id, monitorId, i]
);
}
}
if (patch.subMonitors !== undefined) {
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE status_page_id = $1`, [id]);
for (const [i, monitorId] of patch.subMonitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[id, monitorId, i]
);
}
}
// Re-fetch to return full entity
const freshResult = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
const freshRow = freshResult.rows[0];
if (!freshRow) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.loadEntity(freshRow);
};
deleteById = async (id: string, teamId: string): Promise<StatusPage> => {
// Load entity before delete (join tables cascade)
const existing = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
const row = existing.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
const entity = await this.loadEntity(row);
await this.pool.query(`DELETE FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
return entity;
};
removeMonitorFromStatusPages = async (monitorId: string): Promise<number> => {
// Return count of distinct status pages affected, matching Mongo's modifiedCount
const result = await this.pool.query(
`SELECT COUNT(DISTINCT status_page_id)::int AS count FROM (
SELECT status_page_id FROM status_page_monitors WHERE monitor_id = $1
UNION
SELECT status_page_id FROM status_page_sub_monitors WHERE monitor_id = $1
) affected`,
[monitorId]
);
const affectedCount = result.rows[0]?.count ?? 0;
await this.pool.query(`DELETE FROM status_page_monitors WHERE monitor_id = $1`, [monitorId]);
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE monitor_id = $1`, [monitorId]);
return affectedCount;
};
private loadEntity = async (row: StatusPageRow): Promise<StatusPage> => {
const [monitorsResult, subMonitorsResult] = await Promise.all([
this.pool.query(`SELECT monitor_id FROM status_page_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
this.pool.query(`SELECT monitor_id FROM status_page_sub_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
]);
return {
id: row.id,
userId: row.user_id,
teamId: row.team_id,
type: Array.isArray(row.types) ? row.types : parsePostgresArray(row.types),
companyName: row.company_name,
url: row.url,
timezone: row.timezone ?? undefined,
color: row.color,
monitors: monitorsResult.rows.map((r) => r.monitor_id),
subMonitors: subMonitorsResult.rows.map((r) => r.monitor_id),
originalMonitors: [],
logo: row.logo_data
? {
data: row.logo_data.toString("base64"),
contentType: row.logo_content_type ?? "",
}
: undefined,
isPublished: row.is_published,
showCharts: row.show_charts,
showUptimePercentage: row.show_uptime_percentage,
showAdminLoginLink: row.show_admin_login_link,
showInfrastructure: row.show_infrastructure,
customCSS: row.custom_css ?? "",
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
};
}
@@ -0,0 +1,24 @@
import type { Pool } from "pg";
import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js";
import { Team } from "@/types/team.js";
export class TimescaleTeamsRepository implements ITeamsRepository {
constructor(private pool: Pool) {}
create = async (email: string): Promise<Team> => {
const result = await this.pool.query(`INSERT INTO teams (email) VALUES ($1) RETURNING id, email, created_at, updated_at`, [email]);
return this.toEntity(result.rows[0]);
};
findAllTeamIds = async (): Promise<string[]> => {
const result = await this.pool.query(`SELECT id FROM teams`);
return result.rows.map((row) => row.id);
};
private toEntity = (row: { id: string; email: string; created_at: Date; updated_at: Date }): Team => ({
id: row.id,
email: row.email,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -0,0 +1,222 @@
import type { Pool } from "pg";
import { IUsersRepository } from "./IUsersRepository.js";
import type { User, UserRole } from "@/types/user.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "TimescaleUsersRepository";
// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array
const parsePostgresArray = (value: unknown): UserRole[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as UserRole[];
};
interface UserRow {
id: string;
team_id: string;
first_name: string;
last_name: string;
email: string;
password: string;
avatar_image: string | null;
profile_image: Buffer | null;
profile_image_content_type: string | null;
is_active: boolean;
is_verified: boolean;
roles: UserRole[];
check_ttl: number | null;
created_at: Date;
updated_at: Date;
}
export class TimescaleUsersRepository implements IUsersRepository {
constructor(private pool: Pool) {}
create = async (user: Partial<User>, imageFile?: Express.Multer.File | null): Promise<User> => {
let avatarImage: string | undefined;
let profileImage: Buffer | undefined;
let profileImageContentType: string | undefined;
if (imageFile) {
profileImage = imageFile.buffer;
profileImageContentType = imageFile.mimetype;
avatarImage = await GenerateAvatarImage(imageFile);
}
const result = await this.pool.query<UserRow>(
`INSERT INTO users (team_id, first_name, last_name, email, password, avatar_image, profile_image, profile_image_content_type, is_active, is_verified, roles, check_ttl)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
[
user.teamId || null,
user.firstName,
user.lastName,
user.email,
user.password,
avatarImage || null,
profileImage || null,
profileImageContentType || null,
user.isActive ?? true,
user.isVerified ?? false,
user.role ?? ["user"],
user.checkTTL ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 });
}
const entity = this.toEntity(row);
entity.password = "";
return entity;
};
findByEmail = async (email: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users WHERE email = $1`,
[email]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findById = async (id: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users WHERE id = $1`,
[id]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findAll = async (): Promise<User[]> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users`
);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, patch: Partial<User & { deleteProfileImage?: boolean }>, file?: Express.Multer.File | null): Promise<User> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (ParseBoolean(patch.deleteProfileImage) === true) {
sets.push(`profile_image = NULL`);
sets.push(`profile_image_content_type = NULL`);
sets.push(`avatar_image = NULL`);
} else if (file) {
const avatarImage = await GenerateAvatarImage(file);
sets.push(`profile_image = $${paramIndex++}`);
values.push(file.buffer);
sets.push(`profile_image_content_type = $${paramIndex++}`);
values.push(file.mimetype);
sets.push(`avatar_image = $${paramIndex++}`);
values.push(avatarImage);
}
if (patch.firstName !== undefined) {
sets.push(`first_name = $${paramIndex++}`);
values.push(patch.firstName);
}
if (patch.lastName !== undefined) {
sets.push(`last_name = $${paramIndex++}`);
values.push(patch.lastName);
}
if (patch.email !== undefined) {
sets.push(`email = $${paramIndex++}`);
values.push(patch.email);
}
if (patch.password !== undefined) {
sets.push(`password = $${paramIndex++}`);
values.push(patch.password);
}
if (patch.isActive !== undefined) {
sets.push(`is_active = $${paramIndex++}`);
values.push(patch.isActive);
}
if (patch.isVerified !== undefined) {
sets.push(`is_verified = $${paramIndex++}`);
values.push(patch.isVerified);
}
if (patch.role !== undefined) {
sets.push(`roles = $${paramIndex++}`);
values.push(patch.role);
}
if (patch.teamId !== undefined) {
sets.push(`team_id = $${paramIndex++}`);
values.push(patch.teamId);
}
if (patch.checkTTL !== undefined) {
sets.push(`check_ttl = $${paramIndex++}`);
values.push(patch.checkTTL);
}
if (sets.length === 0) {
return this.findById(id);
}
sets.push(`updated_at = NOW()`);
values.push(id);
const result = await this.pool.query<UserRow>(
`UPDATE users SET ${sets.join(", ")} WHERE id = $${paramIndex}
RETURNING id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`DELETE FROM users WHERE id = $1
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
[id]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findSuperAdmin = async (): Promise<boolean> => {
const result = await this.pool.query(`SELECT 1 FROM users WHERE 'superadmin' = ANY(roles) LIMIT 1`);
return (result.rowCount ?? 0) > 0;
};
private toEntity = (row: UserRow): User => ({
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
email: row.email,
password: row.password,
avatarImage: row.avatar_image ?? undefined,
profileImage: undefined,
isActive: row.is_active,
isVerified: row.is_verified,
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
teamId: row.team_id ?? "",
checkTTL: row.check_ttl ?? undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits["total-blocking-time"]),
};
}
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors
? hardwarePayload.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
@@ -20,7 +20,7 @@ export interface IGeoChecksService {
dateRange: string;
page?: number;
rowsPerPage?: number;
continent: GeoContinent | GeoContinent[];
continent?: GeoContinent | GeoContinent[];
}): Promise<FlatGeoChecksQueryResult>;
}
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
}
}
if (decision.shouldResolveIncident) {
if (!activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
}
return null;
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
};
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
+10 -31
View File
@@ -23,7 +23,6 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "../infrastructure/emailService.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService";
@@ -91,7 +90,6 @@ export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
private emailService: IEmailService;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
@@ -103,7 +101,6 @@ export class MonitorService implements IMonitorService {
constructor({
jobQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -114,7 +111,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
emailService: IEmailService;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
@@ -125,7 +121,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository: IIncidentsRepository;
}) {
this.jobQueue = jobQueue;
this.emailService = emailService;
this.logger = logger;
this.games = games;
this.monitorsRepository = monitorsRepository;
@@ -176,7 +171,7 @@ export class MonitorService implements IMonitorService {
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
@@ -185,8 +180,7 @@ export class MonitorService implements IMonitorService {
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
const monitors = demoMonitorsData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
@@ -214,7 +208,7 @@ export class MonitorService implements IMonitorService {
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -263,7 +257,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -305,7 +299,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -346,7 +340,7 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] };
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
@@ -360,8 +354,7 @@ export class MonitorService implements IMonitorService {
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
return await this.monitorsRepository.findById(monitorId, teamId);
};
getMonitorsByTeamId = async ({
@@ -373,11 +366,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[];
filter?: string;
}): Promise<Monitor[] | null> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
type,
filter,
});
return monitors;
return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
};
getMonitorsWithChecksByTeamId = async ({
@@ -432,8 +421,7 @@ export class MonitorService implements IMonitorService {
};
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId);
return groups;
return await this.monitorsRepository.findGroupsByTeamId(teamId);
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
@@ -574,15 +562,6 @@ export class MonitorService implements IMonitorService {
const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({
message: "Failed to import any monitors. Please check the file format and try again.",
service: SERVICE_NAME,
method: "importMonitorsFromJSON",
status: 400,
});
}
return { imported: createdMonitors.length, errors };
return { imported: createdMonitors!.length, errors };
};
}
+8 -24
View File
@@ -110,10 +110,7 @@ export class UserService implements IUserService {
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
};
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
@@ -253,14 +250,10 @@ export class UserService implements IUserService {
currentUserEmail: string
) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
if (updates.password && updates.newPassword) {
updates.email = currentUserEmail;
// Get user
const user = await this.usersRepository.findByEmail(currentUserEmail);
// Compare passwords
const match = await bcrypt.compare(updates?.password, user.password);
const match = await bcrypt.compare(updates.password, user.password);
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
@@ -286,7 +279,7 @@ export class UserService implements IUserService {
const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName;
const settings = this.settingsService.getSettings();
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`;
const html = await this.emailService.buildEmail("passwordResetTemplate", {
name,
@@ -336,17 +329,10 @@ export class UserService implements IUserService {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
// 1. Find all the monitors associated with the team ID if superadmin
const res = await this.monitorsRepository.findByTeamId(teamId, {});
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
if (res && res.length > 0) {
await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor);
})
);
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors) {
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
}
}
// 6. Delete the user by id
@@ -410,9 +396,7 @@ export class UserService implements IUserService {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
return user;
return await this.usersRepository.findById(userId);
};
editUserById = async (userId: string, patch: Partial<User>) => {
+1
View File
@@ -29,6 +29,7 @@ export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -51,7 +51,6 @@ export interface ISuperSimpleQueue {
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
flushQueues(): Promise<{ success: boolean }>;
obliterate(): Promise<void>;
}
export class SuperSimpleQueue implements ISuperSimpleQueue {
@@ -73,22 +72,91 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return SuperSimpleQueue.SERVICE_NAME;
}
private registerListeners = () => {
this.scheduler.on("scheduler:start", () => {
this.logger.info({
message: "Scheduler started",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:stop", () => {
this.logger.info({
message: "Scheduler stopped",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:error", (error) => {
this.logger.error({
message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:abort", (job, reason) => {
this.logger.warn({
message: `${job.id} aborted: ${reason}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:attempt", (job, attempt) => {
this.logger.debug({
message: `${job.id} attempt ${attempt}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:complete", (job) => {
this.logger.debug({
message: `${job.id} completed successfully`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:exhausted", (job, error) => {
this.logger.error({
message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:fail", (job, error, attempt) => {
this.logger.warn({
message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:start", (job) => {
this.logger.debug({
message: `${job.id} started`,
service: SERVICE_NAME,
});
});
};
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString,
});
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler.start();
this.registerListeners();
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
@@ -146,7 +214,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
deleteJob = async (monitor: Monitor) => {
this.scheduler.removeJob(monitor.id);
this.scheduler.removeJob(`${monitor.id}-geo`);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
};
pauseJob = async (monitor: Monitor) => {
@@ -154,7 +223,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to pause monitor");
}
await this.scheduler.pauseJob(`${monitor.id}-geo`);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -177,31 +248,35 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
});
};
private syncGeoJob = async (monitor: Monitor) => {
const geoJobId = `${monitor.id}-geo`;
const existingGeoJob = await this.scheduler.getJob(geoJobId);
// If geoChecks have been disabled, or the monitor type doesn't support them, remove
if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) {
if (existingGeoJob) this.scheduler.removeJob(geoJobId);
return;
}
// If the job exists, update it
if (existingGeoJob) {
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
return;
}
// Otherwise, create it
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
};
updateJob = async (monitor: Monitor) => {
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
// Handle geo check job lifecycle
const geoJobId = `${monitor.id}-geo`;
if (monitor.geoCheckEnabled && supportsGeoCheck(monitor.type)) {
// Check if geo job exists
const existingGeoJob = await this.scheduler.getJob(geoJobId);
if (existingGeoJob) {
// Update existing geo job
this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor });
} else {
// Create new geo job
this.scheduler.addJob({
id: geoJobId,
template: "geo-check-job",
repeat: monitor.geoCheckInterval,
active: monitor.isActive,
data: monitor,
});
}
} else {
// Remove geo job if disabled or monitor type changed
this.scheduler.removeJob(geoJobId);
}
await this.syncGeoJob(monitor);
};
shutdown = async () => {
@@ -230,8 +305,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
@@ -263,9 +338,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
active: job.active,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
@@ -287,12 +362,4 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
success: Boolean(stopRes && flushRes && initRes),
};
};
obliterate = async () => {
this.logger.warn({
message: "obliterate method not implemented",
service: SERVICE_NAME,
method: "obliterate",
});
};
}
@@ -168,15 +168,17 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
});
}
// Step 7. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
// Step 7. Handle incidents
try {
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
} catch (error: unknown) {
this.logger.warn({
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
});
});
}
} catch (error: unknown) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
@@ -377,7 +379,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return true;
}
}
return false;
return acc;
}
return acc;
}, false);
@@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>;
@@ -72,44 +71,6 @@ export class BufferService implements IBufferService {
}
}
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove.id && check.id) {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
check.metadata.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
check.message === checkToRemove.message
);
});
if (index !== -1) {
this.buffer.splice(index, 1);
return true;
}
return false;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -35,7 +35,6 @@ export class EmailService implements IEmailService {
private logger: ILogger;
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
constructor(
settingsService: ISettingsService,
@@ -54,7 +53,6 @@ export class EmailService implements IEmailService {
this.nodemailer = nodemailer;
this.logger = logger;
this.templateLookup = {};
this.loadTemplate = () => undefined;
this.init();
}
@@ -63,7 +61,7 @@ export class EmailService implements IEmailService {
}
init = () => {
this.loadTemplate = (templateName) => {
const loadTemplate = (templateName: string) => {
try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
@@ -79,12 +77,12 @@ export class EmailService implements IEmailService {
};
this.templateLookup = {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
};
};
@@ -167,7 +165,7 @@ export class EmailService implements IEmailService {
subject: subject,
html: html,
});
return info?.messageId;
return info.messageId;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
if (matchTypes.length > 1) {
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
const matchUsed = exactIdMatch ? "exact ID" : "exact name";
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
this.logger.warn({
message,
@@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
};
}
if (!response) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: "No response from Docker container inspect",
responseTime,
payload: null,
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response?.State?.Status === "running",
status: response.State?.Status === "running",
code: 200,
message: "Docker container status fetched successfully",
responseTime,
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
}
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const target = `${host}:${port}`;
const currentFilePath = fileURLToPath(import.meta.url);
@@ -118,7 +118,7 @@ export class NetworkService implements INetworkService {
},
});
if (response?.data?.status !== "success") return false;
if (response.data?.status !== "success") return false;
return true;
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
@@ -5,6 +5,7 @@ import type { AlertMatrixPayload, Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { ILogger } from "@/utils/logger.js";
import { randomUUID } from "crypto";
export class MatrixProvider implements INotificationProvider {
private logger: ILogger;
@@ -18,63 +19,51 @@ export class MatrixProvider implements INotificationProvider {
if (!homeserverUrl || !accessToken || !roomId) {
return false;
}
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: getTestMessage(),
};
try {
await got.post(url, {
json: body,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: err?.stack,
});
return false;
}
return await this.sendMessageWithBody(notification, body);
};
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) {
return false;
}
const { plainText, htmlText } = this.buildMatrixMessage(message);
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: plainText,
format: "org.matrix.custom.html",
formatted_body: htmlText,
};
return this.sendMessageWithBody(notification, body);
};
private sendMessageWithBody = async (notification: Partial<Notification>, body: unknown): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
const txnId = randomUUID();
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`;
try {
await got.post(url, {
await got.put(url, {
json: body,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix notification failed",
message: `Matrix notification failed : ${err.message}`,
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
stack: err.stack,
});
return false;
}
@@ -0,0 +1,113 @@
const SERVICE_NAME = "TelegramProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class TelegramProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
json: {
chat_id: notification.address,
text: getTestMessage(),
parse_mode: "HTML",
},
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Telegram test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
const text = this.buildTelegramText(message);
try {
await got.post(`https://api.telegram.org/bot${notification.accessToken}/sendMessage`, {
json: {
chat_id: notification.address,
text,
parse_mode: "HTML",
},
});
this.logger.info({
message: "Telegram notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Telegram alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildTelegramText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(`<b>${message.content.title}</b>`);
lines.push(message.content.summary);
lines.push("");
lines.push("<b>Monitor Details:</b>");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("<b>Additional Information:</b>");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("<b>Threshold Breaches:</b>");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`<a href="${message.clientHost}/incidents/${message.monitor.id}">View Incident</a>`);
}
return lines.join("\n");
}
}
@@ -33,6 +33,7 @@ export class NotificationsService implements INotificationsService {
private pagerDutyProvider: INotificationProvider;
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -47,6 +48,7 @@ export class NotificationsService implements INotificationsService {
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -60,6 +62,7 @@ export class NotificationsService implements INotificationsService {
this.pagerDutyProvider = pagerDutyProvider;
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -97,6 +100,8 @@ export class NotificationsService implements INotificationsService {
return await this.emailProvider.sendMessage!(notification, notificationMessage);
case "teams":
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -157,6 +162,8 @@ export class NotificationsService implements INotificationsService {
return await this.webhookProvider.sendTestAlert(notification);
case "teams":
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
default:
return false;
}
@@ -96,6 +96,7 @@ export class StatusService implements IStatusService {
uptimePercentage: 0,
lastResponseTime: 0,
lastCheckTimestamp: 0,
timeOfLastFailure: 0,
};
} else {
// Use existing stats (omit id, monitorId, createdAt, updatedAt)
@@ -147,13 +148,7 @@ export class StatusService implements IStatusService {
}
// Calculate uptime percentage
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
@@ -238,7 +233,7 @@ export class StatusService implements IStatusService {
// Return early if not enough data points
if (monitor.statusWindow.length < monitor.statusWindowSize) {
monitor.status = "initializing";
monitor.status = newStatus;
const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);
return {
monitor: updated,
@@ -268,7 +263,7 @@ export class StatusService implements IStatusService {
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
if (monitor.type === "hardware" && statusResponse.payload) {
const payload = statusResponse.payload as HardwareStatusPayload;
const metrics = payload?.data;
const metrics = payload.data;
if (metrics) {
// Evaluate threshold breaches
@@ -278,9 +273,11 @@ export class StatusService implements IStatusService {
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach =
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
false;
const diskBreach = metrics.disk
? metrics.disk.some(
(d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
)
: false;
const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
@@ -366,27 +363,4 @@ export class StatusService implements IStatusService {
});
}
};
insertCheck = async (check: Check) => {
try {
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: SERVICE_NAME,
method: "insertCheck",
});
return false;
}
this.buffer.addToBuffer(check);
return true;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "insertCheck",
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
stack: error instanceof Error ? error.stack : undefined,
});
}
};
}
+26 -12
View File
@@ -1,5 +1,5 @@
import { ISettingsRepository } from "@/repositories/index.js";
import { Settings, SettingsUpdate } from "@/types/index.js";
import { type DbType, Settings, SettingsUpdate } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
import { ValidatedEnv } from "@/validation/envValidation.js";
import type { StringValue } from "ms";
@@ -12,6 +12,7 @@ export type EnvConfig = {
logLevel: string;
clientHost: string;
dbConnectionString: string;
dbType: DbType;
};
export interface ISettingsService {
@@ -25,10 +26,9 @@ export interface ISettingsService {
export class SettingsService implements ISettingsService {
static SERVICE_NAME = SERVICE_NAME;
private settings: EnvConfig;
private settingsRepository: ISettingsRepository;
private settingsRepository: ISettingsRepository | null = null;
constructor(settingsRepository: ISettingsRepository, env: ValidatedEnv) {
this.settingsRepository = settingsRepository;
constructor(env: ValidatedEnv) {
this.settings = {
jwtSecret: env.JWT_SECRET,
jwtTTL: env.TOKEN_TTL as StringValue,
@@ -36,9 +36,14 @@ export class SettingsService implements ISettingsService {
logLevel: env.LOG_LEVEL,
clientHost: env.CLIENT_HOST,
dbConnectionString: env.DB_CONNECTION_STRING,
dbType: env.DB_TYPE,
};
}
setRepository(settingsRepository: ISettingsRepository) {
this.settingsRepository = settingsRepository;
}
get serviceName() {
return SettingsService.SERVICE_NAME;
}
@@ -48,24 +53,33 @@ export class SettingsService implements ISettingsService {
}
getSettings() {
if (!this.settings) {
throw new Error("Settings have not been loaded");
}
return this.settings;
}
private getRepository(): ISettingsRepository {
if (!this.settingsRepository) {
throw new AppError({
message: "Settings repository not initialized. Call setRepository() after DB connect.",
status: 500,
service: SERVICE_NAME,
});
}
return this.settingsRepository;
}
updateDbSettings = async (newSettings: SettingsUpdate) => {
return await this.settingsRepository.update(newSettings);
return await this.getRepository().update(newSettings);
};
getDBSettings = async () => {
const repo = this.getRepository();
// Remove any old settings
await this.settingsRepository.deleteLegacy();
await repo.deleteLegacy();
let settings = await this.settingsRepository.findSingleton();
let settings = await repo.findSingleton();
if (settings === null) {
await this.settingsRepository.create({});
settings = await this.settingsRepository.findSingleton();
await repo.create({});
settings = await repo.findSingleton();
}
if (!settings) {
+20
View File
@@ -0,0 +1,20 @@
export const DLQItemTypes = ["notification", "incident_create", "incident_resolve"] as const;
export type DLQItemType = (typeof DLQItemTypes)[number];
export const DLQItemStatuses = ["pending", "retrying", "failed"] as const;
export type DLQItemStatus = (typeof DLQItemStatuses)[number];
export interface DLQItem {
id: string;
type: DLQItemType;
status: DLQItemStatus;
payload: Record<string, unknown>;
monitorId: string;
teamId: string;
retryCount: number;
maxRetries: number;
lastError: string;
nextRetryAt: string;
createdAt: string;
updatedAt: string;
}
+1
View File
@@ -14,3 +14,4 @@ export * from "@/types/incident.js";
export * from "@/types/email.js";
export * from "@/types/team.js";
export * from "@/types/maintenanceWindow.js";
export * from "@/types/dlqItem.js";
+1 -1
View File
@@ -1,4 +1,4 @@
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
+3
View File
@@ -1,3 +1,6 @@
export const DbTypes = ["timescaledb", "mongodb"] as const;
export type DbType = (typeof DbTypes)[number];
export interface SettingsThresholds {
cpu?: number;
memory?: number;
+2
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { DbTypes } from "@/types/settings.js";
const envSchema = z.object({
// Server Configuration
@@ -8,6 +9,7 @@ const envSchema = z.object({
// Database
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
DB_TYPE: z.enum(DbTypes).default("mongodb"),
// JWT Authentication
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
+1 -1
View File
@@ -7,7 +7,7 @@ import { booleanCoercion } from "./shared.js";
export const getIncidentsByTeamQueryValidation = z.object({
sortOrder: z.enum(["asc", "desc"]),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).default("all"),
page: z.coerce.number().int().min(0),
rowsPerPage: z.coerce.number().int().min(1),
status: booleanCoercion.optional(),
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more