Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db378120be | ||
|
|
ba5368f942 | ||
|
|
7329306ccf | ||
|
|
365987c287 | ||
|
|
09d6147f75 | ||
|
|
86ab1f2146 | ||
|
|
934f85f505 | ||
|
|
96beda5c47 | ||
|
|
504d6157e5 | ||
|
|
628bca3e29 | ||
|
|
2c76484bad | ||
|
|
22498756cd | ||
|
|
7c61ca490c | ||
|
|
96f9360280 | ||
|
|
eb4a92c84f | ||
|
|
c80720c8c9 | ||
|
|
42e82af9bb | ||
|
|
7636d2d090 | ||
|
|
72728748f8 | ||
|
|
4222d39a8b | ||
|
|
fb69e29adf | ||
|
|
0d90083e63 | ||
|
|
8ab049aa6c | ||
|
|
69b6955ced | ||
|
|
04dfff4ebd | ||
|
|
15b0deb0bd | ||
|
|
a895912b99 | ||
|
|
22d2626a93 | ||
|
|
214fc4899c | ||
|
|
e6088fe06f | ||
|
|
60b1d95b74 | ||
|
|
94ca9896db | ||
|
|
13a7ae82e3 | ||
|
|
de3af64254 | ||
|
|
317d531775 | ||
|
|
2b2db2df6f | ||
|
|
c558c4b635 | ||
|
|
be29cce788 | ||
|
|
e87879970a | ||
|
|
4c94a7c21e | ||
|
|
40968c370b | ||
|
|
a864bc0c05 | ||
|
|
62e86434c2 | ||
|
|
07957d3a1f | ||
|
|
bc088c3589 | ||
|
|
5ea86d8e71 | ||
|
|
0944dba386 | ||
|
|
86acc0c30a | ||
|
|
dcdde845db | ||
|
|
3039fb9b69 | ||
|
|
729600a86f | ||
|
|
51853cb360 | ||
|
|
f7db732d67 | ||
|
|
9e686a2319 | ||
|
|
da6f03ba59 | ||
|
|
276b11603e | ||
|
|
f09f0630dc | ||
|
|
1defa52ae8 | ||
|
|
f43b7584a6 | ||
|
|
ee74584699 | ||
|
|
2422fcc83b | ||
|
|
0ecc2e21e5 | ||
|
|
fe242b1977 | ||
|
|
d1f1d31ea2 | ||
|
|
eacbb227c3 | ||
|
|
ec129e3604 |
No files matched your search
+105
-105
@@ -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
@@ -3,8 +3,10 @@
|
||||
.VSCodeCounter
|
||||
*.sh
|
||||
mongo
|
||||
timescaledb
|
||||
node_modules/
|
||||
docs/architecture
|
||||
docs/reviews
|
||||
docs/todo
|
||||
docs/frontend
|
||||
docs/frontend
|
||||
docs/timescale
|
||||
@@ -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>
|
||||
|
||||
[](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
|
||||
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
|
||||
|
||||
## Our sponsors
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const NotificationChannels = [
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
"telegram",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1271
-1085
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1271
-1106
File diff suppressed because it is too large.
Load diff
+1281
-1084
File diff suppressed because it is too large.
Load diff
+1268
-801
File diff suppressed because it is too large.
Load diff
+1268
-801
File diff suppressed because it is too large.
Load diff
+1271
-1098
File diff suppressed because it is too large.
Load diff
+1299
-1088
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
Generated
+267
-200
@@ -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": {
|
||||
|
||||
+5
-3
@@ -5,7 +5,8 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||
"test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services",
|
||||
"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 +53,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 +79,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
export interface IDb {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
getPool(): Pool | null;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
@@ -73,22 +73,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 +215,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 +224,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 +249,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 +306,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 +339,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -147,13 +147,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();
|
||||
@@ -268,7 +262,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 +272,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -54,18 +59,30 @@ export class SettingsService implements ISettingsService {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export const DbTypes = ["timescaledb", "mongodb"] as const;
|
||||
export type DbType = (typeof DbTypes)[number];
|
||||
|
||||
export interface SettingsThresholds {
|
||||
cpu?: number;
|
||||
memory?: number;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -65,6 +65,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
type: z.literal("teams"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
}),
|
||||
// Telegram notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("telegram"),
|
||||
address: z.string().min(1, "Chat ID is required"),
|
||||
accessToken: z.string().min(1, "Bot token is required"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
export const createMockLogger = () => ({
|
||||
serviceName: "test",
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
cacheLog: jest.fn(),
|
||||
getLogs: jest.fn().mockReturnValue([]),
|
||||
buildLogEntry: jest.fn().mockReturnValue({ level: "info", timestamp: new Date().toISOString() }),
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
import type { Monitor } from "../src/types/monitor.ts";
|
||||
|
||||
const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() });
|
||||
|
||||
const createHelper = (overrides?: Partial<ConstructorParameters<typeof SuperSimpleQueueHelper>[0]>) => {
|
||||
const maintenanceWindowsRepository = {
|
||||
findByMonitorId: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const statusServiceMock = {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }),
|
||||
};
|
||||
const helper = new SuperSimpleQueueHelper({
|
||||
logger: createLogger(),
|
||||
networkService: { requestStatus: jest.fn() },
|
||||
statusService: statusServiceMock,
|
||||
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
|
||||
checkService: { buildCheck: jest.fn().mockResolvedValue({}) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
|
||||
maintenanceWindowsRepository,
|
||||
...overrides,
|
||||
});
|
||||
return { helper, maintenanceWindowsRepository };
|
||||
};
|
||||
|
||||
describe("SuperSimpleQueueHelper", () => {
|
||||
describe("getMonitorJob", () => {
|
||||
it("skips execution when monitor is in maintenance window", async () => {
|
||||
const { helper } = createHelper();
|
||||
const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
|
||||
const job = helper.getMonitorJob();
|
||||
await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor);
|
||||
expect(helper["networkService"].requestStatus).not.toHaveBeenCalled();
|
||||
expect(helper["logger"].debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") })
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("processes monitor status and notifications when active", async () => {
|
||||
const networkResponse = { monitor: { id: "m1" }, status: true };
|
||||
const updatedMonitor = { id: "m1", status: true };
|
||||
const { helper } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
statusService: {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false, code: 200 }),
|
||||
},
|
||||
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getMonitorJob();
|
||||
const monitor = { id: "m1", teamId: "team" } as Monitor;
|
||||
await job(monitor);
|
||||
expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
|
||||
it("throws when monitor id is missing", async () => {
|
||||
const { helper } = createHelper();
|
||||
const job = helper.getMonitorJob();
|
||||
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
|
||||
expect(helper["logger"].warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInMaintenanceWindow", () => {
|
||||
it("returns true when an active window spans now", async () => {
|
||||
const now = new Date();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now.getTime() - 1000).toISOString(),
|
||||
end: new Date(now.getTime() + 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when repeat interval advances window into current time", async () => {
|
||||
const now = Date.now();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now - 7200000).toISOString(),
|
||||
end: new Date(now - 6600000).toISOString(),
|
||||
repeat: 3600000,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no active windows exist", async () => {
|
||||
const { helper } = createHelper();
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-45
@@ -1,19 +1,15 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts";
|
||||
import type { Notification } from "../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../src/types/notificationMessage.ts";
|
||||
import type { Notification } from "../../../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../../../src/types/notificationMessage.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
// Mock got
|
||||
const mockPost = jest.fn();
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
default: { post: jest.fn() },
|
||||
default: { post: mockPost },
|
||||
HTTPError: class HTTPError extends Error {},
|
||||
}));
|
||||
|
||||
const createLogger = () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
});
|
||||
const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts");
|
||||
|
||||
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
||||
id: "notif-1",
|
||||
@@ -58,17 +54,14 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
|
||||
});
|
||||
|
||||
describe("TeamsProvider", () => {
|
||||
let provider: TeamsProvider;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
let gotPost: jest.Mock;
|
||||
let provider: InstanceType<typeof TeamsProvider>;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(async () => {
|
||||
logger = createLogger();
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
provider = new TeamsProvider(logger);
|
||||
const got = await import("got");
|
||||
gotPost = got.default.post as jest.Mock;
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
mockPost.mockReset();
|
||||
mockPost.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
@@ -76,35 +69,33 @@ describe("TeamsProvider", () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends an Adaptive Card test message and returns true on success", async () => {
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(true);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||
const [url, options] = mockPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
// Verify Teams webhook envelope
|
||||
const payload = options.json;
|
||||
expect(payload.type).toBe("message");
|
||||
expect(payload.attachments).toHaveLength(1);
|
||||
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
|
||||
|
||||
// Verify Adaptive Card structure
|
||||
const card = payload.attachments[0].content;
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
expect(card.body[0].type).toBe("TextBlock");
|
||||
expect(card.body[0].text).toContain("test notification");
|
||||
expect(card.body[0].text).toBe("This is a test notification from Checkmate");
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
gotPost.mockRejectedValue(new Error("Network error"));
|
||||
mockPost.mockRejectedValue(new Error("Network error"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
@@ -122,7 +113,7 @@ describe("TeamsProvider", () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a well-formed Adaptive Card and returns true", async () => {
|
||||
@@ -131,9 +122,9 @@ describe("TeamsProvider", () => {
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const [url, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
|
||||
const payload = options.json;
|
||||
@@ -143,18 +134,13 @@ describe("TeamsProvider", () => {
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
|
||||
// Verify body contains expected elements
|
||||
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
|
||||
const factSets = card.body.filter((b: any) => b.type === "FactSet");
|
||||
|
||||
// Title block
|
||||
expect(textBlocks[0].text).toBe(message.content.title);
|
||||
expect(textBlocks[0].color).toBe("attention"); // critical -> attention
|
||||
|
||||
// Summary block
|
||||
expect(textBlocks[0].color).toBe("attention");
|
||||
expect(textBlocks[1].text).toBe(message.content.summary);
|
||||
|
||||
// FactSet with monitor details
|
||||
expect(factSets).toHaveLength(1);
|
||||
const facts = factSets[0].facts;
|
||||
expect(facts).toEqual(
|
||||
@@ -165,11 +151,10 @@ describe("TeamsProvider", () => {
|
||||
])
|
||||
);
|
||||
|
||||
// Incident action button
|
||||
expect(card.actions).toHaveLength(1);
|
||||
expect(card.actions[0].type).toBe("Action.OpenUrl");
|
||||
expect(card.actions[0].title).toBe("View Incident");
|
||||
expect(card.actions[0].url).toContain("/infrastructure/mon-1");
|
||||
expect(card.actions[0].url).toContain("/incidents/inc-1");
|
||||
});
|
||||
|
||||
it("omits actions when no incident is present", async () => {
|
||||
@@ -184,7 +169,7 @@ describe("TeamsProvider", () => {
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.actions).toBeUndefined();
|
||||
});
|
||||
@@ -212,7 +197,7 @@ describe("TeamsProvider", () => {
|
||||
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
|
||||
// Title should use "warning" color
|
||||
@@ -222,7 +207,7 @@ describe("TeamsProvider", () => {
|
||||
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
|
||||
expect(thresholdHeader).toBeDefined();
|
||||
|
||||
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU"));
|
||||
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("**CPU**"));
|
||||
expect(cpuBlock).toBeDefined();
|
||||
expect(cpuBlock.text).toContain("95%");
|
||||
expect(cpuBlock.text).toContain("threshold: 80%");
|
||||
@@ -239,20 +224,20 @@ describe("TeamsProvider", () => {
|
||||
] as const;
|
||||
|
||||
for (const { severity, expected } of severityMap) {
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
mockPost.mockReset();
|
||||
mockPost.mockResolvedValue({});
|
||||
|
||||
const message = createMessage({ severity });
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.body[0].color).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||
mockPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { CheckService } from "../../../src/service/business/checkService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts";
|
||||
import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createMonitorsRepo = () =>
|
||||
({
|
||||
findById: jest.fn().mockResolvedValue({}),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createChecksRepo = () =>
|
||||
({
|
||||
createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)),
|
||||
findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
|
||||
findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }),
|
||||
findSummaryByTeamId: jest.fn().mockResolvedValue({}),
|
||||
deleteByMonitorId: jest.fn().mockResolvedValue(5),
|
||||
deleteByTeamId: jest.fn().mockResolvedValue(10),
|
||||
deleteOlderThan: jest.fn().mockResolvedValue(3),
|
||||
}) as unknown as jest.Mocked<IChecksRepository>;
|
||||
|
||||
const createService = () => {
|
||||
const logger = createMockLogger();
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
const checksRepository = createChecksRepo();
|
||||
const service = new CheckService(monitorsRepository, logger as any, checksRepository);
|
||||
return { service, logger, monitorsRepository, checksRepository };
|
||||
};
|
||||
|
||||
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
|
||||
({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 100,
|
||||
...overrides,
|
||||
}) as MonitorStatusResponse;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CheckService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns checkService", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("checkService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildCheck ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildCheck", () => {
|
||||
it("builds a basic HTTP check with correct fields", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse());
|
||||
|
||||
expect(check).toBeDefined();
|
||||
expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" });
|
||||
expect(check!.status).toBe(true);
|
||||
expect(check!.statusCode).toBe(200);
|
||||
expect(check!.responseTime).toBe(100);
|
||||
expect(check!.message).toBe("OK");
|
||||
expect(check!.id).toBeDefined();
|
||||
expect(check!.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("defaults responseTime to 0 when falsy", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ responseTime: undefined }));
|
||||
expect(check!.responseTime).toBe(0);
|
||||
});
|
||||
|
||||
it("includes timings when provided", () => {
|
||||
const timings = { start: 0, socket: 10, lookup: 20 };
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ timings } as any));
|
||||
expect(check!.timings).toBe(timings);
|
||||
});
|
||||
|
||||
// ── PageSpeed ────────────────────────────────────────────────────────
|
||||
|
||||
describe("pagespeed type", () => {
|
||||
it("extracts category scores and audits from lighthouse result", () => {
|
||||
const payload: PageSpeedStatusPayload = {
|
||||
lighthouseResult: {
|
||||
categories: {
|
||||
accessibility: { score: 0.9 },
|
||||
"best-practices": { score: 0.8 },
|
||||
seo: { score: 0.95 },
|
||||
performance: { score: 0.7 },
|
||||
},
|
||||
audits: {
|
||||
"cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" },
|
||||
"speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" },
|
||||
"first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 },
|
||||
"largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 },
|
||||
"total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.accessibility).toBe(90);
|
||||
expect(check!.bestPractices).toBe(80);
|
||||
expect(check!.seo).toBe(95);
|
||||
expect(check!.performance).toBe(70);
|
||||
expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 }));
|
||||
expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 }));
|
||||
expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" }));
|
||||
});
|
||||
|
||||
it("returns undefined when pagespeed payload is missing", () => {
|
||||
const { service, logger } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any));
|
||||
|
||||
expect(check).toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" }));
|
||||
});
|
||||
|
||||
it("defaults categories and audits to empty when lighthouseResult is missing", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any));
|
||||
|
||||
expect(check!.accessibility).toBe(0);
|
||||
expect(check!.bestPractices).toBe(0);
|
||||
expect(check!.seo).toBe(0);
|
||||
expect(check!.performance).toBe(0);
|
||||
expect(check!.audits!.cls).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles audit with non-number score (string)", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.cls!.score).toBe("informative");
|
||||
});
|
||||
|
||||
it("handles audit with null score", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"cumulative-layout-shift": { id: "cls", title: "CLS", score: null },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.cls!.score).toBeNull();
|
||||
});
|
||||
|
||||
it("handles audit with non-number numericValue", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.si!.numericValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non-object audit", () => {
|
||||
const payload = {
|
||||
lighthouseResult: {
|
||||
categories: {},
|
||||
audits: {
|
||||
"cumulative-layout-shift": null,
|
||||
"speed-index": "not an object",
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any));
|
||||
|
||||
expect(check!.audits!.cls).toBeUndefined();
|
||||
expect(check!.audits!.si).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Hardware ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("hardware type", () => {
|
||||
it("extracts cpu, memory, disk, host, net, and capture from payload", () => {
|
||||
const payload: HardwareStatusPayload = {
|
||||
data: {
|
||||
cpu: { usage_percent: 0.5 },
|
||||
memory: { usage_percent: 0.6 },
|
||||
disk: [{ device: "/dev/sda", usage_percent: 0.7 }],
|
||||
host: { os: "linux" },
|
||||
net: [{ name: "eth0" }],
|
||||
},
|
||||
capture: { screenshot: "base64data" },
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
|
||||
|
||||
expect(check!.cpu).toEqual({ usage_percent: 0.5 });
|
||||
expect(check!.memory).toEqual({ usage_percent: 0.6 });
|
||||
expect(check!.disk).toHaveLength(1);
|
||||
expect(check!.host).toEqual({ os: "linux" });
|
||||
expect(check!.net).toHaveLength(1);
|
||||
expect(check!.capture).toEqual({ screenshot: "base64data" });
|
||||
});
|
||||
|
||||
it("extracts errors from array format", () => {
|
||||
const payload = {
|
||||
data: {},
|
||||
errors: [{ message: "timeout" }],
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
|
||||
|
||||
expect(check!.errors).toEqual([{ message: "timeout" }]);
|
||||
});
|
||||
|
||||
it("extracts errors from nested object format", () => {
|
||||
const payload = {
|
||||
data: {},
|
||||
errors: { errors: [{ message: "nested error" }] },
|
||||
} as any;
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any));
|
||||
|
||||
expect(check!.errors).toEqual([{ message: "nested error" }]);
|
||||
});
|
||||
|
||||
it("handles undefined payload gracefully", () => {
|
||||
const { service } = createService();
|
||||
const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any));
|
||||
|
||||
expect(check).toBeDefined();
|
||||
expect(check!.cpu).toBeUndefined();
|
||||
expect(check!.memory).toBeUndefined();
|
||||
expect(check!.disk).toBeUndefined();
|
||||
expect(check!.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── createChecks ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("createChecks", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
const checks = [{ id: "c1" }] as any;
|
||||
await service.createChecks(checks);
|
||||
expect(checksRepository.createChecks).toHaveBeenCalledWith(checks);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChecksByMonitor ───────────────────────────────────────────────────
|
||||
|
||||
describe("getChecksByMonitor", () => {
|
||||
it("returns checks from repository", async () => {
|
||||
const expected = { checks: [{ id: "c1" }], count: 1 };
|
||||
const { service, checksRepository } = createService();
|
||||
(checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
page: 0,
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
page: null as any,
|
||||
rowsPerPage: null as any,
|
||||
});
|
||||
|
||||
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined);
|
||||
});
|
||||
|
||||
it("passes filter and status parameters", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "asc",
|
||||
dateRange: "week",
|
||||
page: 2,
|
||||
rowsPerPage: 20,
|
||||
filter: "error",
|
||||
status: false,
|
||||
});
|
||||
|
||||
expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false);
|
||||
});
|
||||
|
||||
it("throws when monitorId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(
|
||||
service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
|
||||
).rejects.toThrow("No monitor ID in request");
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(
|
||||
service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 })
|
||||
).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("verifies monitor belongs to team via repository", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
|
||||
await service.getChecksByMonitor({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
sortOrder: "desc",
|
||||
dateRange: "day",
|
||||
page: 0,
|
||||
rowsPerPage: 10,
|
||||
});
|
||||
|
||||
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChecksByTeam ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getChecksByTeam", () => {
|
||||
it("returns checks from repository", async () => {
|
||||
const expected = { checks: [], count: 0 };
|
||||
const { service, checksRepository } = createService();
|
||||
(checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected);
|
||||
|
||||
const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 });
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any });
|
||||
|
||||
expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getChecksSummaryByTeamId ──────────────────────────────────────────────
|
||||
|
||||
describe("getChecksSummaryByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const summary = { total: 5, up: 4, down: 1 };
|
||||
const { service, checksRepository } = createService();
|
||||
(checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
|
||||
|
||||
const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" });
|
||||
|
||||
expect(result).toBe(summary);
|
||||
expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day");
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteChecks ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("deleteChecks", () => {
|
||||
it("verifies monitor ownership and deletes", async () => {
|
||||
const { service, monitorsRepository, checksRepository } = createService();
|
||||
|
||||
const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" });
|
||||
|
||||
expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1");
|
||||
expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1");
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChecksByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
const result = await service.deleteChecksByTeamId({ teamId: "team-1" });
|
||||
|
||||
expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1");
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOlderThan", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const date = new Date();
|
||||
const { service, checksRepository } = createService();
|
||||
|
||||
const result = await service.deleteOlderThan(date);
|
||||
|
||||
expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { IncidentService } from "../../../src/service/business/incidentService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../../src/repositories/index.ts";
|
||||
import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts";
|
||||
import type { Monitor } from "../../../src/types/monitor.ts";
|
||||
import type { Incident } from "../../../src/types/index.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createIncidentsRepo = () =>
|
||||
({
|
||||
findActiveByMonitorId: jest.fn(),
|
||||
findActiveByIncidentId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateById: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByTeamId: jest.fn(),
|
||||
countByTeamId: jest.fn(),
|
||||
findSummaryByTeamId: jest.fn(),
|
||||
}) as unknown as jest.Mocked<IIncidentsRepository>;
|
||||
|
||||
const createMonitorsRepo = () =>
|
||||
({
|
||||
findById: jest.fn(),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createUsersRepo = () =>
|
||||
({
|
||||
findById: jest.fn(),
|
||||
}) as unknown as jest.Mocked<IUsersRepository>;
|
||||
|
||||
const createMessageBuilder = () =>
|
||||
({
|
||||
extractThresholdBreaches: jest.fn(),
|
||||
}) as unknown as jest.Mocked<INotificationMessageBuilder>;
|
||||
|
||||
const createService = (overrides?: {
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
incidentsRepository?: ReturnType<typeof createIncidentsRepo>;
|
||||
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
|
||||
usersRepository?: ReturnType<typeof createUsersRepo>;
|
||||
notificationMessageBuilder?: ReturnType<typeof createMessageBuilder>;
|
||||
}) => {
|
||||
const logger = overrides?.logger ?? createMockLogger();
|
||||
const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo();
|
||||
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
|
||||
const usersRepository = overrides?.usersRepository ?? createUsersRepo();
|
||||
const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder();
|
||||
|
||||
const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
|
||||
return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder };
|
||||
};
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
status: "down",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeIncident = (overrides?: Partial<Incident>): Incident =>
|
||||
({
|
||||
id: "inc-1",
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
startTime: "1700000000000",
|
||||
status: true,
|
||||
statusCode: 500,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Incident;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("IncidentService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns incidentService", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("incidentService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleIncident ───────────────────────────────────────────────────────
|
||||
|
||||
describe("handleIncident", () => {
|
||||
it("returns null when neither create nor resolve is requested", async () => {
|
||||
const { service } = createService();
|
||||
const result = await service.handleIncident(makeMonitor(), 200, makeDecision());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns existing active incident when shouldCreateIncident and one already exists", async () => {
|
||||
const existing = makeIncident();
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
||||
|
||||
expect(result).toBe(existing);
|
||||
expect(incidentsRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a new incident when shouldCreateIncident and no active incident exists", async () => {
|
||||
const created = makeIncident();
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }));
|
||||
|
||||
expect(result).toBe(created);
|
||||
expect(incidentsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
status: true,
|
||||
statusCode: 500,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
|
||||
const created = makeIncident();
|
||||
const { service, incidentsRepository, notificationMessageBuilder } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
|
||||
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
|
||||
]);
|
||||
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
|
||||
|
||||
expect(incidentsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusCode: 9999,
|
||||
message: "CPU: 95% (threshold: 80%)",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => {
|
||||
const created = makeIncident();
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
await service.handleIncident(makeMonitor(), 200, decision, undefined);
|
||||
|
||||
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
|
||||
});
|
||||
|
||||
it("uses fallback message when extractThresholdBreaches returns empty array", async () => {
|
||||
const created = makeIncident();
|
||||
const { service, incidentsRepository, notificationMessageBuilder } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
|
||||
|
||||
expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" }));
|
||||
});
|
||||
|
||||
it("joins multiple threshold breaches with commas", async () => {
|
||||
const created = makeIncident();
|
||||
const { service, incidentsRepository, notificationMessageBuilder } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
(notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([
|
||||
{ metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" },
|
||||
{ metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" },
|
||||
]);
|
||||
|
||||
const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" });
|
||||
await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any);
|
||||
|
||||
expect(incidentsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" })
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves active incident when shouldResolveIncident", async () => {
|
||||
const active = makeIncident();
|
||||
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
|
||||
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
|
||||
|
||||
expect(result).toBe(resolved);
|
||||
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
|
||||
"inc-1",
|
||||
"team-1",
|
||||
expect.objectContaining({ status: false, resolutionType: "automatic" })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when shouldResolveIncident but no active incident exists", async () => {
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveIncident ──────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveIncident", () => {
|
||||
it("resolves incident with manual resolution", async () => {
|
||||
const active = makeIncident();
|
||||
const resolved = makeIncident({ status: false });
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
|
||||
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
|
||||
|
||||
const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com");
|
||||
|
||||
expect(result).toBe(resolved);
|
||||
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
|
||||
"inc-1",
|
||||
"team-1",
|
||||
expect.objectContaining({
|
||||
resolutionType: "manual",
|
||||
status: false,
|
||||
resolvedBy: "user-1",
|
||||
resolvedByEmail: "user@test.com",
|
||||
comment: "Fixed it",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sets resolvedByEmail and comment to null when not provided", async () => {
|
||||
const active = makeIncident();
|
||||
const resolved = makeIncident({ status: false });
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
|
||||
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
|
||||
|
||||
await service.resolveIncident("inc-1", "user-1", "team-1");
|
||||
|
||||
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
|
||||
"inc-1",
|
||||
"team-1",
|
||||
expect.objectContaining({
|
||||
resolvedByEmail: null,
|
||||
comment: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when incidentId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request");
|
||||
});
|
||||
|
||||
it("throws when userId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request");
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("throws when incident is not found", async () => {
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found");
|
||||
});
|
||||
|
||||
it("throws when incident is already resolved", async () => {
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false }));
|
||||
|
||||
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved");
|
||||
});
|
||||
|
||||
it("logs error and rethrows on unexpected failure", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error"));
|
||||
|
||||
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" })
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── getIncidentsByTeam ───────────────────────────────────────────────────
|
||||
|
||||
describe("getIncidentsByTeam", () => {
|
||||
it("returns incidents and count", async () => {
|
||||
const incidents = [makeIncident()];
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents);
|
||||
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
|
||||
|
||||
const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined);
|
||||
|
||||
expect(result).toEqual({ incidents, count: 1 });
|
||||
});
|
||||
|
||||
it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => {
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
|
||||
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
|
||||
|
||||
await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined);
|
||||
|
||||
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it("passes filter parameters through to repository", async () => {
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
|
||||
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
|
||||
|
||||
await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual");
|
||||
|
||||
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual");
|
||||
expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual");
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("logs error and rethrows on unexpected failure", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
|
||||
|
||||
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" }));
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42);
|
||||
|
||||
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── getIncidentSummary ───────────────────────────────────────────────────
|
||||
|
||||
describe("getIncidentSummary", () => {
|
||||
it("returns summary from repository", async () => {
|
||||
const summary = { recentIncidents: [], totalIncidents: 0 };
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
|
||||
|
||||
const result = await service.getIncidentSummary("team-1", 5);
|
||||
|
||||
expect(result).toBe(summary);
|
||||
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5);
|
||||
});
|
||||
|
||||
it("defaults limit to 10 when not provided", async () => {
|
||||
const { service, incidentsRepository } = createService();
|
||||
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.getIncidentSummary("team-1");
|
||||
|
||||
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10);
|
||||
});
|
||||
|
||||
it("throws when teamId is missing", async () => {
|
||||
const { service } = createService();
|
||||
await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request");
|
||||
});
|
||||
|
||||
it("logs error and rethrows on unexpected failure", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
|
||||
|
||||
await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" }));
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom");
|
||||
|
||||
await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── getIncidentById ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getIncidentById", () => {
|
||||
it("returns incident, monitor, and user when resolvedBy exists", async () => {
|
||||
const incident = makeIncident({ resolvedBy: "user-1" });
|
||||
const monitor = makeMonitor();
|
||||
const user = { id: "user-1", email: "user@test.com" };
|
||||
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
|
||||
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(usersRepository.findById as jest.Mock).mockResolvedValue(user);
|
||||
|
||||
const result = await service.getIncidentById("inc-1", "team-1");
|
||||
|
||||
expect(result).toEqual({ incident, monitor, user });
|
||||
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("returns user as null when resolvedBy is not set", async () => {
|
||||
const incident = makeIncident({ resolvedBy: undefined });
|
||||
const monitor = makeMonitor();
|
||||
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
|
||||
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
|
||||
const result = await service.getIncidentById("inc-1", "team-1");
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(usersRepository.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error and rethrows on unexpected failure", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found"));
|
||||
|
||||
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } })
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger, incidentsRepository } = createService();
|
||||
(incidentsRepository.findById as jest.Mock).mockRejectedValue(null);
|
||||
|
||||
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
-63
@@ -1,6 +1,13 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { MonitorService } from "../src/service/business/monitorService.ts";
|
||||
import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts";
|
||||
import { MonitorService } from "../../../src/service/business/monitorService.ts";
|
||||
import type {
|
||||
IChecksRepository,
|
||||
IGeoChecksRepository,
|
||||
IIncidentsRepository,
|
||||
IMonitorStatsRepository,
|
||||
IMonitorsRepository,
|
||||
IStatusPagesRepository,
|
||||
} from "../../../src/repositories/index.ts";
|
||||
|
||||
const createMonitorsRepositoryMock = () =>
|
||||
({
|
||||
@@ -8,16 +15,11 @@ const createMonitorsRepositoryMock = () =>
|
||||
findByTeamId: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findMonitorsSummaryByTeamId: jest.fn(),
|
||||
findGroupsByTeamId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createBulkMonitors: jest.fn(),
|
||||
deleteByTeamId: jest.fn(),
|
||||
}) as unknown as IMonitorsRepository;
|
||||
|
||||
const createChecksRepositoryMock = () =>
|
||||
({
|
||||
findLatestChecksByMonitorIds: jest.fn(),
|
||||
findDateRangeChecksByMonitor: jest.fn(),
|
||||
findByDateRangeAndMonitorId: jest.fn(),
|
||||
}) as unknown as IChecksRepository;
|
||||
|
||||
const createMonitorStatsRepositoryMock = () =>
|
||||
@@ -31,76 +33,84 @@ const createStatusPagesRepositoryMock = () =>
|
||||
removeMonitorFromStatusPages: jest.fn(),
|
||||
}) as unknown as IStatusPagesRepository;
|
||||
|
||||
const createGeoChecksRepositoryMock = () => ({}) as unknown as IGeoChecksRepository;
|
||||
|
||||
const createIncidentsRepositoryMock = () => ({}) as unknown as IIncidentsRepository;
|
||||
|
||||
const createService = ({
|
||||
monitorsRepository = createMonitorsRepositoryMock(),
|
||||
checksRepository = createChecksRepositoryMock(),
|
||||
monitorStatsRepository = createMonitorStatsRepositoryMock(),
|
||||
statusPagesRepository = createStatusPagesRepositoryMock(),
|
||||
geoChecksRepository = createGeoChecksRepositoryMock(),
|
||||
incidentsRepository = createIncidentsRepositoryMock(),
|
||||
}: {
|
||||
monitorsRepository?: IMonitorsRepository;
|
||||
checksRepository?: IChecksRepository;
|
||||
monitorStatsRepository?: IMonitorStatsRepository;
|
||||
statusPagesRepository?: IStatusPagesRepository;
|
||||
geoChecksRepository?: IGeoChecksRepository;
|
||||
incidentsRepository?: IIncidentsRepository;
|
||||
} = {}) => {
|
||||
return new MonitorService({
|
||||
db: {
|
||||
checkModule: { deleteChecks: jest.fn() },
|
||||
statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() },
|
||||
pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() },
|
||||
notificationModule: { deleteNotificationsByMonitorId: jest.fn() },
|
||||
},
|
||||
jobQueue: {
|
||||
addJob: jest.fn(),
|
||||
updateJob: jest.fn(),
|
||||
resumeJob: jest.fn(),
|
||||
pauseJob: jest.fn(),
|
||||
deleteJob: jest.fn(),
|
||||
},
|
||||
stringService: {},
|
||||
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() },
|
||||
papaparse: { parse: jest.fn(), unparse: jest.fn() },
|
||||
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
errorService: {
|
||||
createAuthorizationError: jest.fn(() => new Error("unauthorized")),
|
||||
createServerError: jest.fn(() => new Error("server")),
|
||||
createBadRequestError: jest.fn(() => new Error("bad request")),
|
||||
createNotFoundError: jest.fn(() => new Error("not found")),
|
||||
},
|
||||
} as any,
|
||||
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() } as any,
|
||||
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
|
||||
games: [],
|
||||
monitorsRepository,
|
||||
checksRepository,
|
||||
geoChecksRepository,
|
||||
monitorStatsRepository,
|
||||
statusPagesRepository,
|
||||
incidentsRepository,
|
||||
});
|
||||
};
|
||||
|
||||
describe("MonitorService", () => {
|
||||
describe("getMonitorsWithChecksByTeamId", () => {
|
||||
it("returns monitors enriched with normalized checks", async () => {
|
||||
it("returns monitors enriched with normalized recentChecks", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
|
||||
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
|
||||
totalMonitors: 2,
|
||||
upMonitors: 2,
|
||||
downMonitors: 0,
|
||||
pausedMonitors: 0,
|
||||
});
|
||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
|
||||
{ id: "m1", name: "Monitor 1", interval: 60000 },
|
||||
{ id: "m2", name: "Monitor 2", interval: 60000 },
|
||||
{
|
||||
id: "m1",
|
||||
name: "Monitor 1",
|
||||
type: "http",
|
||||
interval: 60000,
|
||||
recentChecks: [
|
||||
{ responseTime: 10, status: true, message: "OK" },
|
||||
{ responseTime: 20, status: true, message: "OK" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
name: "Monitor 2",
|
||||
type: "http",
|
||||
interval: 60000,
|
||||
recentChecks: [{ responseTime: 50, status: true, message: "OK" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({
|
||||
m1: [
|
||||
{ responseTime: 10, status: true, message: "OK" },
|
||||
{ responseTime: 20, status: true, message: "OK" },
|
||||
],
|
||||
m2: [{ responseTime: 50, status: true, message: "OK" }],
|
||||
});
|
||||
|
||||
const service = createService({ monitorsRepository, checksRepository });
|
||||
const service = createService({ monitorsRepository });
|
||||
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
||||
|
||||
expect(result).toMatchObject({ count: 2 });
|
||||
expect(result.monitors).toHaveLength(2);
|
||||
expect(result.monitors[0]).toHaveProperty("checks");
|
||||
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
|
||||
expect(result.monitors[0].checks[0]).toEqual(
|
||||
expect(result.monitors[0]).toHaveProperty("recentChecks");
|
||||
expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0);
|
||||
expect(result.monitors[0].recentChecks[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
responseTime: expect.any(Number),
|
||||
status: expect.any(Boolean),
|
||||
@@ -120,15 +130,15 @@ describe("MonitorService", () => {
|
||||
const service = createService({ monitorsRepository });
|
||||
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("id", "m1");
|
||||
expect(result![0]).toHaveProperty("id", "m1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMonitorsWithChecksByTeamId summary", () => {
|
||||
it("includes summary and monitors with checks", async () => {
|
||||
it("includes summary and monitors with recentChecks", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
|
||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]);
|
||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http", recentChecks: [] }]);
|
||||
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
|
||||
totalMonitors: 1,
|
||||
upMonitors: 1,
|
||||
@@ -136,15 +146,12 @@ describe("MonitorService", () => {
|
||||
pausedMonitors: 0,
|
||||
});
|
||||
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] });
|
||||
|
||||
const service = createService({ monitorsRepository, checksRepository });
|
||||
const service = createService({ monitorsRepository });
|
||||
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
||||
expect(result).toEqual({
|
||||
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
|
||||
count: 1,
|
||||
monitors: [{ id: "m1", type: "http", checks: [] }],
|
||||
monitors: [{ id: "m1", type: "http", recentChecks: [] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -155,23 +162,11 @@ describe("MonitorService", () => {
|
||||
const monitor = {
|
||||
id: "monitor-1",
|
||||
teamId: TEAM_ID,
|
||||
name: "Hardware monitor",
|
||||
name: "HTTP monitor",
|
||||
interval: 60000,
|
||||
statusWindow: [],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 60,
|
||||
type: "http",
|
||||
ignoreTlsErrors: false,
|
||||
url: "https://example.com",
|
||||
isActive: true,
|
||||
alertThreshold: 5,
|
||||
cpuAlertThreshold: 5,
|
||||
memoryAlertThreshold: 5,
|
||||
diskAlertThreshold: 5,
|
||||
tempAlertThreshold: 5,
|
||||
selectedDisks: [],
|
||||
notifications: [],
|
||||
group: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -179,7 +174,7 @@ describe("MonitorService", () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
const checksRepository = createChecksRepositoryMock();
|
||||
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
|
||||
(checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({
|
||||
monitorType: "http",
|
||||
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
|
||||
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts";
|
||||
import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createProvider = () => ({
|
||||
sendMessage: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
|
||||
sendTestAlert: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
const createNotificationsRepo = () => ({
|
||||
create: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findNotificationsByIds: jest.fn(),
|
||||
findByTeamId: jest.fn(),
|
||||
updateById: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
});
|
||||
|
||||
const createMonitorsRepo = () => ({
|
||||
removeNotificationFromMonitors: jest.fn(),
|
||||
});
|
||||
|
||||
const createSettingsService = (clientHost = "https://app.example.com") => ({
|
||||
getSettings: jest.fn().mockReturnValue({ clientHost }),
|
||||
});
|
||||
|
||||
const createMessageBuilder = () => ({
|
||||
buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }),
|
||||
extractThresholdBreaches: jest.fn(),
|
||||
});
|
||||
|
||||
const createService = (overrides?: Record<string, unknown>) => {
|
||||
const logger = createMockLogger();
|
||||
const notificationsRepository = createNotificationsRepo();
|
||||
const monitorsRepository = createMonitorsRepo();
|
||||
const webhookProvider = createProvider();
|
||||
const emailProvider = createProvider();
|
||||
const slackProvider = createProvider();
|
||||
const discordProvider = createProvider();
|
||||
const pagerDutyProvider = createProvider();
|
||||
const matrixProvider = createProvider();
|
||||
const teamsProvider = createProvider();
|
||||
const telegramProvider = createProvider();
|
||||
const settingsService = createSettingsService();
|
||||
const notificationMessageBuilder = createMessageBuilder();
|
||||
|
||||
const defaults = {
|
||||
logger,
|
||||
notificationsRepository,
|
||||
monitorsRepository,
|
||||
webhookProvider,
|
||||
emailProvider,
|
||||
slackProvider,
|
||||
discordProvider,
|
||||
pagerDutyProvider,
|
||||
matrixProvider,
|
||||
teamsProvider,
|
||||
telegramProvider,
|
||||
settingsService,
|
||||
notificationMessageBuilder,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const service = new NotificationsService(
|
||||
defaults.notificationsRepository as any,
|
||||
defaults.monitorsRepository as any,
|
||||
defaults.webhookProvider as any,
|
||||
defaults.emailProvider as any,
|
||||
defaults.slackProvider as any,
|
||||
defaults.discordProvider as any,
|
||||
defaults.pagerDutyProvider as any,
|
||||
defaults.matrixProvider as any,
|
||||
defaults.teamsProvider as any,
|
||||
defaults.telegramProvider as any,
|
||||
defaults.settingsService as any,
|
||||
defaults.logger as any,
|
||||
defaults.notificationMessageBuilder as any
|
||||
);
|
||||
|
||||
return { service, ...defaults };
|
||||
};
|
||||
|
||||
const makeNotification = (overrides?: Partial<Notification>): Notification =>
|
||||
({
|
||||
id: "notif-1",
|
||||
userId: "user-1",
|
||||
teamId: "team-1",
|
||||
type: "email",
|
||||
notificationName: "Email Alert",
|
||||
address: "test@example.com",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Notification;
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
notifications: ["notif-1"],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: null,
|
||||
notificationReason: "status_change",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse;
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotificationsService", () => {
|
||||
// ── handleNotifications ───────────────────────────────────────────────────
|
||||
|
||||
describe("handleNotifications", () => {
|
||||
it("returns false when shouldSendNotification is false", async () => {
|
||||
const { service } = createService();
|
||||
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision({ shouldSendNotification: false }));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("sends notifications to all configured providers and returns true", async () => {
|
||||
const { service, notificationsRepository, emailProvider } = createService();
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
|
||||
|
||||
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes to correct provider for each notification type", async () => {
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const;
|
||||
for (const type of types) {
|
||||
const deps = createService();
|
||||
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
|
||||
|
||||
await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
|
||||
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
slack: deps.slackProvider,
|
||||
matrix: deps.matrixProvider,
|
||||
pager_duty: deps.pagerDutyProvider,
|
||||
discord: deps.discordProvider,
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
};
|
||||
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false and logs warning for unknown notification type", async () => {
|
||||
const { service, notificationsRepository, logger } = createService();
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]);
|
||||
|
||||
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Unknown notification type: carrier_pigeon") })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false and logs warning when notificationMessage is undefined", async () => {
|
||||
const notificationMessageBuilder = createMessageBuilder();
|
||||
notificationMessageBuilder.buildMessage.mockReturnValue(undefined);
|
||||
const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder });
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
|
||||
|
||||
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" }));
|
||||
});
|
||||
|
||||
it("handles monitors with no notification IDs", async () => {
|
||||
const { service, notificationsRepository } = createService();
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeStatusResponse(), makeDecision());
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("returns false and logs when some notifications fail", async () => {
|
||||
const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService();
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
|
||||
makeNotification({ id: "n1", type: "email" }),
|
||||
makeNotification({ id: "n2", type: "slack" }),
|
||||
]);
|
||||
emailProvider.sendMessage.mockResolvedValue(true);
|
||||
slackProvider.sendMessage.mockResolvedValue(false);
|
||||
|
||||
const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") }));
|
||||
});
|
||||
|
||||
it("uses fallback clientHost when settings.clientHost is empty", async () => {
|
||||
const settingsService = createSettingsService("");
|
||||
const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService });
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]);
|
||||
|
||||
await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision());
|
||||
|
||||
expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"Host not defined"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sendTestNotification ─────────────────────────────────────────────────
|
||||
|
||||
describe("sendTestNotification", () => {
|
||||
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)(
|
||||
"routes %s to the correct provider",
|
||||
async (type) => {
|
||||
const deps = createService();
|
||||
const notification = makeNotification({ type: type as any });
|
||||
|
||||
const result = await deps.service.sendTestNotification(notification);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const providerMap: Record<string, ReturnType<typeof createProvider>> = {
|
||||
webhook: deps.webhookProvider,
|
||||
slack: deps.slackProvider,
|
||||
matrix: deps.matrixProvider,
|
||||
pager_duty: deps.pagerDutyProvider,
|
||||
discord: deps.discordProvider,
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
};
|
||||
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
|
||||
}
|
||||
);
|
||||
|
||||
it("returns false for unknown notification type", async () => {
|
||||
const { service } = createService();
|
||||
const result = await service.sendTestNotification(makeNotification({ type: "unknown" as any }));
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── testAllNotifications ─────────────────────────────────────────────────
|
||||
|
||||
describe("testAllNotifications", () => {
|
||||
it("returns true when all test alerts succeed", async () => {
|
||||
const { service, notificationsRepository } = createService();
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([
|
||||
makeNotification({ type: "email" }),
|
||||
makeNotification({ type: "slack" }),
|
||||
]);
|
||||
|
||||
const result = await service.testAllNotifications(["notif-1", "notif-2"]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when any test alert fails", async () => {
|
||||
const { service, notificationsRepository, emailProvider } = createService();
|
||||
emailProvider.sendTestAlert.mockResolvedValue(false);
|
||||
(notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]);
|
||||
|
||||
const result = await service.testAllNotifications(["notif-1"]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── CRUD operations ──────────────────────────────────────────────────────
|
||||
|
||||
describe("createNotification", () => {
|
||||
it("sets userId and teamId and delegates to repository", async () => {
|
||||
const created = makeNotification();
|
||||
const { service, notificationsRepository } = createService();
|
||||
(notificationsRepository.create as jest.Mock).mockResolvedValue(created);
|
||||
|
||||
const result = await service.createNotification({ type: "email", address: "a@b.com" }, "user-1", "team-1");
|
||||
|
||||
expect(result).toBe(created);
|
||||
expect(notificationsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", teamId: "team-1", type: "email" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const notification = makeNotification();
|
||||
const { service, notificationsRepository } = createService();
|
||||
(notificationsRepository.findById as jest.Mock).mockResolvedValue(notification);
|
||||
|
||||
const result = await service.findById("notif-1", "team-1");
|
||||
expect(result).toBe(notification);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNotificationsByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const notifications = [makeNotification()];
|
||||
const { service, notificationsRepository } = createService();
|
||||
(notificationsRepository.findByTeamId as jest.Mock).mockResolvedValue(notifications);
|
||||
|
||||
const result = await service.findNotificationsByTeamId("team-1");
|
||||
expect(result).toBe(notifications);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateById", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const updated = makeNotification({ address: "new@example.com" });
|
||||
const { service, notificationsRepository } = createService();
|
||||
(notificationsRepository.updateById as jest.Mock).mockResolvedValue(updated);
|
||||
|
||||
const result = await service.updateById("notif-1", "team-1", { address: "new@example.com" });
|
||||
expect(result).toBe(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteById", () => {
|
||||
it("deletes notification and removes from monitors", async () => {
|
||||
const deleted = makeNotification();
|
||||
const { service, notificationsRepository, monitorsRepository } = createService();
|
||||
(notificationsRepository.deleteById as jest.Mock).mockResolvedValue(deleted);
|
||||
|
||||
const result = await service.deleteById("notif-1", "team-1");
|
||||
|
||||
expect(result).toBe(deleted);
|
||||
expect(monitorsRepository.removeNotificationFromMonitors).toHaveBeenCalledWith("notif-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,889 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { StatusService } from "../../../src/service/infrastructure/statusService.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts";
|
||||
import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts";
|
||||
import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const createBuffer = (): jest.Mocked<Pick<IBufferService, "addToBuffer">> => ({
|
||||
addToBuffer: jest.fn(),
|
||||
});
|
||||
|
||||
const createMonitorsRepo = () =>
|
||||
({
|
||||
findById: jest.fn(),
|
||||
updateById: jest.fn(),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createMonitorStatsRepo = () =>
|
||||
({
|
||||
findByMonitorId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateByMonitorId: jest.fn(),
|
||||
}) as unknown as jest.Mocked<IMonitorStatsRepository>;
|
||||
|
||||
const createChecksRepo = () => ({}) as unknown as jest.Mocked<IChecksRepository>;
|
||||
|
||||
const createService = (overrides?: {
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
buffer?: ReturnType<typeof createBuffer>;
|
||||
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
|
||||
monitorStatsRepository?: ReturnType<typeof createMonitorStatsRepo>;
|
||||
checksRepository?: ReturnType<typeof createChecksRepo>;
|
||||
}) => {
|
||||
const logger = overrides?.logger ?? createMockLogger();
|
||||
const buffer = overrides?.buffer ?? createBuffer();
|
||||
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
|
||||
const monitorStatsRepository = overrides?.monitorStatsRepository ?? createMonitorStatsRepo();
|
||||
const checksRepository = overrides?.checksRepository ?? createChecksRepo();
|
||||
|
||||
const service = new StatusService(logger as any, buffer as any, monitorsRepository, monitorStatsRepository, checksRepository);
|
||||
return { service, logger, buffer, monitorsRepository, monitorStatsRepository, checksRepository };
|
||||
};
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
userId: "user-1",
|
||||
teamId: "team-1",
|
||||
name: "Test Monitor",
|
||||
type: "http",
|
||||
url: "https://example.com",
|
||||
isActive: true,
|
||||
interval: 60000,
|
||||
status: "up",
|
||||
statusWindow: [],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
recentChecks: [],
|
||||
cpuAlertThreshold: 80,
|
||||
memoryAlertThreshold: 80,
|
||||
diskAlertThreshold: 80,
|
||||
tempAlertThreshold: 80,
|
||||
cpuAlertCounter: 5,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertCounter: 5,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeStatusResponse = (overrides?: Partial<MonitorStatusResponse>): MonitorStatusResponse =>
|
||||
({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "http",
|
||||
status: true,
|
||||
code: 200,
|
||||
message: "OK",
|
||||
responseTime: 100,
|
||||
...overrides,
|
||||
}) as MonitorStatusResponse;
|
||||
|
||||
const makeCheck = (overrides?: Partial<Check>): Check =>
|
||||
({
|
||||
id: "check-1",
|
||||
metadata: { monitorId: "mon-1", teamId: "team-1" },
|
||||
status: true,
|
||||
responseTime: 100,
|
||||
statusCode: 200,
|
||||
message: "OK",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Check;
|
||||
|
||||
const makeExistingStats = (overrides?: Record<string, unknown>) => ({
|
||||
id: "stats-1",
|
||||
monitorId: "mon-1",
|
||||
avgResponseTime: 100,
|
||||
maxResponseTime: 200,
|
||||
totalChecks: 10,
|
||||
totalUpChecks: 9,
|
||||
totalDownChecks: 1,
|
||||
uptimePercentage: 0.9,
|
||||
lastResponseTime: 90,
|
||||
lastCheckTimestamp: 1000,
|
||||
timeOfLastFailure: 500,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("StatusService", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns StatusService", () => {
|
||||
const { service } = createService();
|
||||
expect(service.serviceName).toBe("StatusService");
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateRunningStats ───────────────────────────────────────────────────
|
||||
|
||||
describe("updateRunningStats", () => {
|
||||
it("creates new stats when none exist", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockRejectedValue(new Error("not found"));
|
||||
(monitorStatsRepository.create as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(monitorStatsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
totalChecks: 1,
|
||||
totalUpChecks: 1,
|
||||
avgResponseTime: 50,
|
||||
lastResponseTime: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("updates existing stats for a successful check", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 110 }));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
|
||||
"mon-1",
|
||||
expect.objectContaining({
|
||||
totalChecks: 11,
|
||||
totalUpChecks: 10,
|
||||
totalDownChecks: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("increments totalDownChecks and resets timeOfLastFailure on failure", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: false, responseTime: 100 }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
|
||||
"mon-1",
|
||||
expect.objectContaining({
|
||||
totalDownChecks: 2,
|
||||
timeOfLastFailure: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sets timeOfLastFailure when status is up and it was previously 0", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ timeOfLastFailure: 0 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: true }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith(
|
||||
"mon-1",
|
||||
expect.objectContaining({
|
||||
timeOfLastFailure: expect.any(Number),
|
||||
})
|
||||
);
|
||||
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(call[1].timeOfLastFailure).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("updates maxResponseTime when new response is higher", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 300 }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 300 }));
|
||||
});
|
||||
|
||||
it("does not update maxResponseTime when new response is lower", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 200 }));
|
||||
});
|
||||
|
||||
it("initializes avgResponseTime when previous avg was 0", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 0 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 75 }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 75 }));
|
||||
});
|
||||
|
||||
it("computes running average when avgResponseTime is nonzero", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, totalChecks: 10 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 210 }));
|
||||
|
||||
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
|
||||
// (100 * 10 + 210) / 11 = 110
|
||||
expect(call[1].avgResponseTime).toBe(110);
|
||||
});
|
||||
|
||||
it("leaves avgResponseTime unchanged when responseTime is undefined", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined }));
|
||||
|
||||
expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 100 }));
|
||||
});
|
||||
|
||||
it("handles responseTime of 0 (falsy but defined)", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, maxResponseTime: 200 }));
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({});
|
||||
|
||||
await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 }));
|
||||
|
||||
const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(call[1].lastResponseTime).toBe(0);
|
||||
// responseTime 0 is falsy, so maxResponseTime stays unchanged
|
||||
expect(call[1].maxResponseTime).toBe(200);
|
||||
});
|
||||
|
||||
it("returns false and logs error when an exception is thrown", async () => {
|
||||
const { service, logger, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed"));
|
||||
|
||||
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
service: "StatusService",
|
||||
method: "updateRunningStats",
|
||||
message: "db write failed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs error with 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, logger, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats());
|
||||
(monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
|
||||
it("creates new stats when findByMonitorId resolves to null", async () => {
|
||||
const { service, monitorStatsRepository } = createService();
|
||||
(monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null);
|
||||
(monitorStatsRepository.create as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50, status: true }));
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(monitorStatsRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateMonitorStatus ──────────────────────────────────────────────────
|
||||
|
||||
describe("updateMonitorStatus", () => {
|
||||
it("returns early with no status change when statusWindow is not full", async () => {
|
||||
const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "up" });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck());
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.prevStatus).toBe("up");
|
||||
});
|
||||
|
||||
it("pushes to statusWindow and trims to statusWindowSize", async () => {
|
||||
const monitor = makeMonitor({ statusWindow: [true, true, true, true, true], statusWindowSize: 5 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck());
|
||||
|
||||
// Window should have shifted: [true, true, true, true, false]
|
||||
expect(monitor.statusWindow).toHaveLength(5);
|
||||
expect(monitor.statusWindow[4]).toBe(false);
|
||||
});
|
||||
|
||||
it("pushes check snapshot to recentChecks and trims to 25", async () => {
|
||||
const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` }));
|
||||
const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" }));
|
||||
|
||||
expect(monitor.recentChecks).toHaveLength(25);
|
||||
expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" }));
|
||||
});
|
||||
|
||||
it("marks status as down when failure threshold is met", async () => {
|
||||
// 5/5 failures = 100% >= 80% threshold
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [false, false, false, false],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "up",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("recovers to up when failure rate drops below threshold", async () => {
|
||||
// 1/5 failures = 20% < 80% threshold, and monitor was down
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [true, true, true, false],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "down",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }));
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
it("does not change status when already up and below threshold", async () => {
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "up",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck());
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
it("does not change status when already down and still above threshold", async () => {
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [false, false, false, false],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "down",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
|
||||
|
||||
expect(result.statusChanged).toBe(false);
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("initializes statusWindow when undefined", async () => {
|
||||
const monitor = makeMonitor({ statusWindow: undefined as any, statusWindowSize: 5 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
|
||||
|
||||
expect(monitor.statusWindow).toEqual([true]);
|
||||
});
|
||||
|
||||
it("initializes recentChecks when undefined", async () => {
|
||||
const monitor = makeMonitor({ recentChecks: undefined as any, statusWindowSize: 5 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
|
||||
|
||||
expect(monitor.recentChecks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws AppError when repository throws", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockRejectedValue(new Error("db error"));
|
||||
|
||||
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Failed to update monitor");
|
||||
});
|
||||
|
||||
it("throws AppError with 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockRejectedValue("string error");
|
||||
|
||||
await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Unknown error");
|
||||
});
|
||||
|
||||
it("returns code and timestamp in result", async () => {
|
||||
const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck());
|
||||
|
||||
expect(result.code).toBe(201);
|
||||
expect(result.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Hardware threshold breach tests ──────────────────────────────────
|
||||
|
||||
describe("hardware threshold breaches", () => {
|
||||
const makeHardwareMonitor = (overrides?: Partial<Monitor>) =>
|
||||
makeMonitor({
|
||||
type: "hardware",
|
||||
statusWindow: [true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
status: "up",
|
||||
cpuAlertThreshold: 80,
|
||||
memoryAlertThreshold: 80,
|
||||
diskAlertThreshold: 80,
|
||||
tempAlertThreshold: 80,
|
||||
cpuAlertCounter: 5,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertCounter: 5,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial<MonitorStatusResponse<HardwareStatusPayload>>) =>
|
||||
makeStatusResponse({
|
||||
type: "hardware",
|
||||
status: true,
|
||||
payload,
|
||||
...overrides,
|
||||
} as any) as MonitorStatusResponse<HardwareStatusPayload>;
|
||||
|
||||
it("detects CPU threshold breach and decrements counter", async () => {
|
||||
const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.cpu).toBe(true);
|
||||
expect(result.thresholdBreaches?.memory).toBe(false);
|
||||
expect(monitor.cpuAlertCounter).toBe(0);
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
it("detects memory threshold breach", async () => {
|
||||
const monitor = makeHardwareMonitor({ memoryAlertCounter: 1 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.memory).toBe(true);
|
||||
expect(monitor.memoryAlertCounter).toBe(0);
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
it("detects disk threshold breach", async () => {
|
||||
const monitor = makeHardwareMonitor({ diskAlertCounter: 1 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(true);
|
||||
expect(monitor.diskAlertCounter).toBe(0);
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
it("detects temperature threshold breach", async () => {
|
||||
const monitor = makeHardwareMonitor({ tempAlertCounter: 1 });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.temp).toBe(true);
|
||||
expect(monitor.tempAlertCounter).toBe(0);
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
});
|
||||
|
||||
it("resets counters to 5 when thresholds are not breached", async () => {
|
||||
const monitor = makeHardwareMonitor({
|
||||
cpuAlertCounter: 2,
|
||||
memoryAlertCounter: 2,
|
||||
diskAlertCounter: 2,
|
||||
tempAlertCounter: 2,
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
|
||||
} as any);
|
||||
await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(monitor.cpuAlertCounter).toBe(5);
|
||||
expect(monitor.memoryAlertCounter).toBe(5);
|
||||
expect(monitor.diskAlertCounter).toBe(5);
|
||||
expect(monitor.tempAlertCounter).toBe(5);
|
||||
});
|
||||
|
||||
it("stays breached without statusChanged when already breached and counter still at 0", async () => {
|
||||
const monitor = makeHardwareMonitor({
|
||||
status: "breached",
|
||||
cpuAlertCounter: 1,
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.monitor.status).toBe("breached");
|
||||
expect(result.statusChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers from breached to up when all thresholds return to normal", async () => {
|
||||
const monitor = makeHardwareMonitor({ status: "breached" });
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.statusChanged).toBe(true);
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
it("does not override down status with breached", async () => {
|
||||
// Monitor is down due to failure threshold, hardware breaches should not override
|
||||
const monitor = makeHardwareMonitor({
|
||||
statusWindow: [false, false, false, false],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "up",
|
||||
cpuAlertCounter: 1,
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
status: false,
|
||||
data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck({ status: false }));
|
||||
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("handles missing cpu usage_percent (returns -1)", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.cpu).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing memory usage_percent", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.memory).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty temperature array", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.temp).toBe(false);
|
||||
});
|
||||
|
||||
it("handles undefined temperature", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.temp).toBe(false);
|
||||
});
|
||||
|
||||
it("skips threshold evaluation when payload is undefined", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse(undefined as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips threshold evaluation when payload.data is undefined", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set thresholdBreaches for non-hardware monitors", async () => {
|
||||
const monitor = makeMonitor({
|
||||
type: "http",
|
||||
statusWindow: [true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles null entry in disk array", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(false);
|
||||
});
|
||||
|
||||
it("handles disk with undefined usage_percent entries", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(false);
|
||||
});
|
||||
|
||||
it("handles nullish cpu in metrics", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.cpu).toBe(false);
|
||||
expect(result.thresholdBreaches?.temp).toBe(false);
|
||||
});
|
||||
|
||||
it("handles nullish memory in metrics", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.memory).toBe(false);
|
||||
});
|
||||
|
||||
it("handles nullish disk in metrics", async () => {
|
||||
const monitor = makeHardwareMonitor();
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} },
|
||||
} as any);
|
||||
const result = await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(result.thresholdBreaches?.disk).toBe(false);
|
||||
});
|
||||
|
||||
it("counters do not go below 0", async () => {
|
||||
const monitor = makeHardwareMonitor({
|
||||
cpuAlertCounter: 0,
|
||||
memoryAlertCounter: 0,
|
||||
diskAlertCounter: 0,
|
||||
tempAlertCounter: 0,
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const response = makeHardwareResponse({
|
||||
data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} },
|
||||
} as any);
|
||||
await service.updateMonitorStatus(response, makeCheck());
|
||||
|
||||
expect(monitor.cpuAlertCounter).toBe(0);
|
||||
expect(monitor.memoryAlertCounter).toBe(0);
|
||||
expect(monitor.diskAlertCounter).toBe(0);
|
||||
expect(monitor.tempAlertCounter).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── insertCheck ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("insertCheck", () => {
|
||||
it("adds check to buffer and returns true", async () => {
|
||||
const { service, buffer } = createService();
|
||||
const check = makeCheck();
|
||||
|
||||
const result = await service.insertCheck(check);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(buffer.addToBuffer).toHaveBeenCalledWith(check);
|
||||
});
|
||||
|
||||
it("returns false and logs warning when check is undefined", async () => {
|
||||
const { service, logger } = createService();
|
||||
|
||||
const result = await service.insertCheck(undefined as any);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Failed to build check",
|
||||
service: "StatusService",
|
||||
method: "insertCheck",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs error when buffer throws", async () => {
|
||||
const buffer = createBuffer();
|
||||
(buffer.addToBuffer as jest.Mock).mockImplementation(() => {
|
||||
throw new Error("buffer full");
|
||||
});
|
||||
const { service, logger } = createService({ buffer });
|
||||
|
||||
const result = await service.insertCheck(makeCheck());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "buffer full",
|
||||
service: "StatusService",
|
||||
method: "insertCheck",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nullish check in error path", async () => {
|
||||
const buffer = createBuffer();
|
||||
(buffer.addToBuffer as jest.Mock).mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const { service, logger } = createService({ buffer });
|
||||
|
||||
await service.insertCheck(null as any);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
details: { msg: "Error inserting check for monitor: undefined" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions in insertCheck", async () => {
|
||||
const buffer = createBuffer();
|
||||
(buffer.addToBuffer as jest.Mock).mockImplementation(() => {
|
||||
throw "string error";
|
||||
});
|
||||
const { service, logger } = createService({ buffer });
|
||||
|
||||
await service.insertCheck(makeCheck());
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user