Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c4780e75b |
No files matched your search
+105
-105
@@ -1,120 +1,120 @@
|
|||||||
# name: POEditor Translation Synchronization
|
name: POEditor Translation Synchronization
|
||||||
|
|
||||||
# on:
|
on:
|
||||||
# # For manual triggering
|
# For manual triggering
|
||||||
# workflow_dispatch:
|
workflow_dispatch:
|
||||||
# inputs:
|
inputs:
|
||||||
# languages:
|
languages:
|
||||||
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
||||||
# required: false
|
required: false
|
||||||
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
||||||
# format:
|
format:
|
||||||
# description: "Export format (key_value_json or json)"
|
description: "Export format (key_value_json or json)"
|
||||||
# required: false
|
required: false
|
||||||
# default: "key_value_json"
|
default: "key_value_json"
|
||||||
|
|
||||||
# permissions:
|
permissions:
|
||||||
# contents: write
|
contents: write
|
||||||
# pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
# jobs:
|
jobs:
|
||||||
# sync-translations:
|
sync-translations:
|
||||||
# runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# permissions:
|
permissions:
|
||||||
# contents: write
|
contents: write
|
||||||
# issues: write
|
issues: write
|
||||||
# pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
# steps:
|
steps:
|
||||||
# - name: Checkout code
|
- name: Checkout code
|
||||||
# uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
# with:
|
with:
|
||||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# - name: Setup Node.js
|
- name: Setup Node.js
|
||||||
# uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
# with:
|
with:
|
||||||
# node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
# - name: Create package.json for scripts
|
- name: Create package.json for scripts
|
||||||
# run: |
|
run: |
|
||||||
# mkdir -p .github/scripts
|
mkdir -p .github/scripts
|
||||||
# cat > .github/scripts/package.json << EOF
|
cat > .github/scripts/package.json << EOF
|
||||||
# {
|
{
|
||||||
# "name": "poeditor-scripts",
|
"name": "poeditor-scripts",
|
||||||
# "version": "1.0.0",
|
"version": "1.0.0",
|
||||||
# "private": true,
|
"private": true,
|
||||||
# "type": "module",
|
"type": "module",
|
||||||
# "dependencies": {
|
"dependencies": {
|
||||||
# "axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
# "fs-extra": "^11.1.1"
|
"fs-extra": "^11.1.1"
|
||||||
# }
|
}
|
||||||
# }
|
}
|
||||||
# EOF
|
EOF
|
||||||
|
|
||||||
# - name: Install dependencies
|
- name: Install dependencies
|
||||||
# run: |
|
run: |
|
||||||
# cd .github/scripts
|
cd .github/scripts
|
||||||
# npm install
|
npm install
|
||||||
|
|
||||||
# - name: Download translations from POEditor
|
- name: Download translations from POEditor
|
||||||
# env:
|
env:
|
||||||
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
||||||
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
||||||
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||||
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
||||||
# run: |
|
run: |
|
||||||
# mkdir -p temp
|
mkdir -p temp
|
||||||
# node .github/scripts/download-translations.js
|
node .github/scripts/download-translations.js
|
||||||
|
|
||||||
# - name: Verify translation files
|
- name: Verify translation files
|
||||||
# run: |
|
run: |
|
||||||
# echo "Verifying translation files..."
|
echo "Verifying translation files..."
|
||||||
# for file in temp/*.json; do
|
for file in temp/*.json; do
|
||||||
# echo "Checking $file"
|
echo "Checking $file"
|
||||||
# if [ ! -s "$file" ]; then
|
if [ ! -s "$file" ]; then
|
||||||
# echo "Error: $file is empty or does not exist"
|
echo "Error: $file is empty or does not exist"
|
||||||
# exit 1
|
exit 1
|
||||||
# fi
|
fi
|
||||||
# # Validate JSON format
|
# Validate JSON format
|
||||||
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
||||||
# done
|
done
|
||||||
# echo "All translation files are valid"
|
echo "All translation files are valid"
|
||||||
|
|
||||||
# - name: Copy translations to project
|
- name: Copy translations to project
|
||||||
# run: |
|
run: |
|
||||||
# mkdir -p client/src/locales
|
mkdir -p client/src/locales
|
||||||
# cp -r temp/* client/src/locales/
|
cp -r temp/* client/src/locales/
|
||||||
# echo "Translation files copied to client/src/locales/"
|
echo "Translation files copied to client/src/locales/"
|
||||||
|
|
||||||
# - name: Get current date
|
- name: Get current date
|
||||||
# id: date
|
id: date
|
||||||
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# - name: Format client code
|
- name: Format client code
|
||||||
# run: |
|
run: |
|
||||||
# cd client
|
cd client
|
||||||
# npm ci
|
npm ci
|
||||||
# npm run format
|
npm run format
|
||||||
|
|
||||||
# - name: Create Pull Request
|
- name: Create Pull Request
|
||||||
# uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@v7
|
||||||
# with:
|
with:
|
||||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# commit-message: "feat: translations updated from POEditor"
|
commit-message: "feat: translations updated from POEditor"
|
||||||
# title: "🌐 Updated Translations from POEditor"
|
title: "🌐 Updated Translations from POEditor"
|
||||||
# body: |
|
body: |
|
||||||
# This PR contains the latest translations from POEditor.
|
This PR contains the latest translations from POEditor.
|
||||||
|
|
||||||
# 📅 Update Date: ${{ steps.date.outputs.date }}
|
📅 Update Date: ${{ steps.date.outputs.date }}
|
||||||
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
||||||
|
|
||||||
# ⚠️ Please review the translations and approve the PR if everything looks correct.
|
⚠️ Please review the translations and approve the PR if everything looks correct.
|
||||||
# branch: translation-update-${{ github.run_number }}
|
branch: translation-update-${{ github.run_number }}
|
||||||
# delete-branch: true
|
delete-branch: true
|
||||||
# base: develop
|
base: develop
|
||||||
# add-paths: |
|
add-paths: |
|
||||||
# client/src/locales/*.json
|
client/src/locales/*.json
|
||||||
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||||
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
||||||
+1
-3
@@ -3,10 +3,8 @@
|
|||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
*.sh
|
*.sh
|
||||||
mongo
|
mongo
|
||||||
timescaledb
|
|
||||||
node_modules/
|
node_modules/
|
||||||
docs/architecture
|
docs/architecture
|
||||||
docs/reviews
|
docs/reviews
|
||||||
docs/todo
|
docs/todo
|
||||||
docs/frontend
|
docs/frontend
|
||||||
docs/timescale
|
|
||||||
@@ -29,6 +29,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
|
|||||||
- [📦 Demo](#demo)
|
- [📦 Demo](#demo)
|
||||||
- [🔗 User's guide](#users-guide)
|
- [🔗 User's guide](#users-guide)
|
||||||
- [🛠️ Installation](#installation)
|
- [🛠️ Installation](#installation)
|
||||||
|
- [🏁 Translations](#translations)
|
||||||
- [🚀 Performance](#performance)
|
- [🚀 Performance](#performance)
|
||||||
- [💚 Questions & Ideas](#questions--ideas)
|
- [💚 Questions & Ideas](#questions--ideas)
|
||||||
- [🧩 Features](#features)
|
- [🧩 Features](#features)
|
||||||
@@ -47,15 +48,11 @@ You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.
|
|||||||
|
|
||||||
Usage instructions can be found [here](https://checkmate.so/docs).
|
Usage instructions can be found [here](https://checkmate.so/docs).
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- [Docker](https://www.docker.com/) installed
|
|
||||||
- [Git](https://git-scm.com/) installed
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
||||||
|
|
||||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
Alternatively, you can also use [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.
|
||||||
|
|
||||||
### Using a Custom CA
|
### Using a Custom CA
|
||||||
|
|
||||||
@@ -63,6 +60,11 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
|
|||||||
|
|
||||||
For more documentation, see the [docs directory](./docs/).
|
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
|
## 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:
|
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:
|
||||||
@@ -78,7 +80,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:
|
If you have any questions, suggestions or comments, you have several options:
|
||||||
|
|
||||||
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
|
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
|
||||||
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
|
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
|
||||||
|
|
||||||
Feel free to ask questions or share your ideas - we'd love to hear from you!
|
Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||||
|
|
||||||
@@ -165,7 +167,7 @@ Here's how you can contribute:
|
|||||||
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
|
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
|
[](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
|
||||||
|
|
||||||
## Our sponsors
|
## Our sponsors
|
||||||
|
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,76 +1,28 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { notificationSchema } from "@/Validation/notifications";
|
import { notificationSchema } from "@/Validation/notifications";
|
||||||
import type { NotificationFormData } from "@/Validation/notifications";
|
|
||||||
import type { Notification } from "@/Types/Notification";
|
import type { Notification } from "@/Types/Notification";
|
||||||
|
|
||||||
interface UseNotificationFormOptions {
|
interface UseNotificationFormOptions {
|
||||||
data?: Notification | null;
|
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 = {}) => {
|
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const defaults = buildDefaults(data);
|
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 || "",
|
||||||
|
};
|
||||||
|
|
||||||
return { schema: notificationSchema, defaults };
|
return { schema: notificationSchema, defaults };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
};
|
};
|
||||||
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{watchedType !== "matrix" && watchedType !== "telegram" && (
|
{watchedType !== "matrix" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={addressConfig.title}
|
title={addressConfig.title}
|
||||||
subtitle={addressConfig.description}
|
subtitle={addressConfig.description}
|
||||||
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="address"
|
name="address"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={"address" in defaults ? defaults.address : ""}
|
defaultValue={defaults.address}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
@@ -171,49 +171,6 @@ 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" && (
|
{watchedType === "matrix" && (
|
||||||
<ConfigBox
|
<ConfigBox
|
||||||
title={t("pages.notifications.form.matrix.title")}
|
title={t("pages.notifications.form.matrix.title")}
|
||||||
@@ -223,7 +180,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="homeserverUrl"
|
name="homeserverUrl"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
|
defaultValue={defaults.homeserverUrl}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
@@ -239,7 +196,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="roomId"
|
name="roomId"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
|
defaultValue={defaults.roomId}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
@@ -255,7 +212,7 @@ const NotificationsCreatePage = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="accessToken"
|
name="accessToken"
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
defaultValue={defaults.accessToken}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const NotificationChannels = [
|
|||||||
"pager_duty",
|
"pager_duty",
|
||||||
"matrix",
|
"matrix",
|
||||||
"teams",
|
"teams",
|
||||||
"telegram",
|
|
||||||
] as const;
|
] as const;
|
||||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,6 @@ const teamsSchema = baseSchema.extend({
|
|||||||
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
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", [
|
export const notificationSchema = z.discriminatedUnion("type", [
|
||||||
emailSchema,
|
emailSchema,
|
||||||
slackSchema,
|
slackSchema,
|
||||||
@@ -64,7 +58,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
pagerDutySchema,
|
pagerDutySchema,
|
||||||
matrixSchema,
|
matrixSchema,
|
||||||
teamsSchema,
|
teamsSchema,
|
||||||
telegramSchema,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||||
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
@@ -951,14 +951,6 @@
|
|||||||
"description": "Select the type of notification channel to create.",
|
"description": "Select the type of notification channel to create.",
|
||||||
"optionType": "Type",
|
"optionType": "Type",
|
||||||
"title": "Channel 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": {
|
"table": {
|
||||||
|
|||||||
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1085
-1282
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1086
-1272
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1107
-1272
File diff suppressed because it is too large.
Load diff
+1085
-1282
File diff suppressed because it is too large.
Load diff
+792
-1259
File diff suppressed because it is too large.
Load diff
+792
-1259
File diff suppressed because it is too large.
Load diff
+1099
-1272
File diff suppressed because it is too large.
Load diff
+1089
-1300
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,8 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
|
"^@/validation/(.*)\\.js$": "<rootDir>/src/validation/$1.js",
|
||||||
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.ts",
|
"^@/utils/(AppError)\\.js$": "<rootDir>/src/utils/$1.ts",
|
||||||
|
"^@/utils/(.*)\\.js$": "<rootDir>/src/utils/$1.js",
|
||||||
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
"^@/(.*)\\.ts$": "<rootDir>/src/$1.ts",
|
||||||
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
|
||||||
"^@/(.*)$": "<rootDir>/src/$1",
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
|
|||||||
Generated
+200
-267
@@ -37,11 +37,10 @@
|
|||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
"pg": "8.20.0",
|
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"sharp": "0.33.5",
|
"sharp": "0.33.5",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
"super-simple-scheduler": "1.9.1",
|
"super-simple-scheduler": "1.4.5",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"winston": "^3.13.0",
|
"winston": "^3.13.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
@@ -63,12 +62,12 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "7.0.1",
|
"@types/nodemailer": "7.0.1",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/pg": "8.20.0",
|
|
||||||
"@types/ping": "0.4.4",
|
"@types/ping": "0.4.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
"@typescript-eslint/parser": "^8.56.1",
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
|
"c8": "10.1.3",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-mocha": "^10.5.0",
|
"eslint-plugin-mocha": "^10.5.0",
|
||||||
"esm": "3.2.25",
|
"esm": "3.2.25",
|
||||||
@@ -800,20 +799,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/xml-builder": {
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
"version": "3.972.16",
|
"version": "3.972.10",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz",
|
||||||
"integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==",
|
"integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/types": "^4.13.1",
|
"@smithy/types": "^4.13.0",
|
||||||
"fast-xml-parser": "5.5.8",
|
"fast-xml-parser": "5.4.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"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": {
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
|
||||||
@@ -854,7 +873,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -1357,6 +1375,16 @@
|
|||||||
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||||
@@ -1474,7 +1502,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -1519,7 +1546,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3824,9 +3850,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@smithy/types": {
|
"node_modules/@smithy/types": {
|
||||||
"version": "4.13.1",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
|
||||||
"integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
|
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4311,7 +4337,6 @@
|
|||||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -4472,7 +4497,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4498,18 +4522,6 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/ping": {
|
||||||
"version": "0.4.4",
|
"version": "0.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz",
|
||||||
@@ -4722,7 +4734,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -4931,9 +4942,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5336,7 +5347,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5874,9 +5884,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5915,7 +5925,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -6042,6 +6051,40 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/cacheable-lookup": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
|
||||||
@@ -6824,7 +6867,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
|
||||||
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
|
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssnano-preset-default": "^7.0.9",
|
"cssnano-preset-default": "^7.0.9",
|
||||||
"lilconfig": "^3.1.3"
|
"lilconfig": "^3.1.3"
|
||||||
@@ -7592,7 +7634,6 @@
|
|||||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -7954,7 +7995,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -8087,9 +8127,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.4",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8097,31 +8137,7 @@
|
|||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
@@ -8262,9 +8278,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.2",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -8711,9 +8727,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -8813,9 +8829,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/handlebars": {
|
"node_modules/handlebars": {
|
||||||
"version": "4.7.9",
|
"version": "4.7.8",
|
||||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.5",
|
"minimist": "^1.2.5",
|
||||||
@@ -9623,7 +9639,6 @@
|
|||||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "30.2.0",
|
"@jest/core": "30.2.0",
|
||||||
"@jest/types": "30.2.0",
|
"@jest/types": "30.2.0",
|
||||||
@@ -10115,9 +10130,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jest-util/node_modules/picomatch": {
|
"node_modules/jest-util/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -10652,9 +10667,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.camelcase": {
|
"node_modules/lodash.camelcase": {
|
||||||
@@ -11196,9 +11211,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mjml-cli/node_modules/brace-expansion": {
|
"node_modules/mjml-cli/node_modules/brace-expansion": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -12292,22 +12307,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -12344,9 +12343,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-type": {
|
"node_modules/path-type": {
|
||||||
@@ -12359,96 +12358,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -12456,9 +12365,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -12600,7 +12509,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -13074,45 +12982,6 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/posthtml": {
|
||||||
"version": "0.16.7",
|
"version": "0.16.7",
|
||||||
"resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz",
|
||||||
@@ -14158,15 +14027,6 @@
|
|||||||
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
|
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -14484,9 +14344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
"node_modules/strnum": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -14513,12 +14373,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/super-simple-scheduler": {
|
"node_modules/super-simple-scheduler": {
|
||||||
"version": "1.9.1",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.4.5.tgz",
|
||||||
"integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==",
|
"integrity": "sha512-xHR3a+pLywY7HtwjWj/+OaANdEwAieQhckC/i0s6OoHtVJQ4+VLO1VfkKUbxp+oSROGXcaZet8PTGONVdKNUUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"uuid": "11.1.0"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/super-simple-scheduler/node_modules/uuid": {
|
"node_modules/super-simple-scheduler/node_modules/uuid": {
|
||||||
@@ -14534,6 +14407,28 @@
|
|||||||
"uuid": "dist/esm/bin/uuid"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -14621,6 +14516,47 @@
|
|||||||
"node": ">=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": {
|
"node_modules/text-hex": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||||
@@ -14663,12 +14599,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -14837,7 +14772,6 @@
|
|||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
@@ -15000,7 +14934,6 @@
|
|||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -15054,9 +14987,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.24.1",
|
"version": "6.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
@@ -15724,9 +15657,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+3
-5
@@ -5,8 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
|
||||||
"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",
|
"dev": "nodemon --exec tsx src/index.js",
|
||||||
"start": "node --watch ./dist/index.js",
|
"start": "node --watch ./dist/index.js",
|
||||||
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
|
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
|
||||||
@@ -53,11 +52,10 @@
|
|||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
"pg": "8.20.0",
|
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"sharp": "0.33.5",
|
"sharp": "0.33.5",
|
||||||
"ssl-checker": "2.0.10",
|
"ssl-checker": "2.0.10",
|
||||||
"super-simple-scheduler": "1.9.1",
|
"super-simple-scheduler": "1.4.5",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"winston": "^3.13.0",
|
"winston": "^3.13.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
@@ -79,12 +77,12 @@
|
|||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/nodemailer": "7.0.1",
|
"@types/nodemailer": "7.0.1",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/pg": "8.20.0",
|
|
||||||
"@types/ping": "0.4.4",
|
"@types/ping": "0.4.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
"@typescript-eslint/parser": "^8.56.1",
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
|
"c8": "10.1.3",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-mocha": "^10.5.0",
|
"eslint-plugin-mocha": "^10.5.0",
|
||||||
"esm": "3.2.25",
|
"esm": "3.2.25",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
PagerDutyProvider,
|
PagerDutyProvider,
|
||||||
MatrixProvider,
|
MatrixProvider,
|
||||||
TeamsProvider,
|
TeamsProvider,
|
||||||
TelegramProvider,
|
|
||||||
// Interfaces
|
// Interfaces
|
||||||
INetworkService,
|
INetworkService,
|
||||||
IEmailService,
|
IEmailService,
|
||||||
@@ -47,7 +46,6 @@ import {
|
|||||||
IIncidentService,
|
IIncidentService,
|
||||||
INotificationMessageBuilder,
|
INotificationMessageBuilder,
|
||||||
ISettingsService,
|
ISettingsService,
|
||||||
SettingsService,
|
|
||||||
EnvConfig,
|
EnvConfig,
|
||||||
} from "@/service/index.js";
|
} from "@/service/index.js";
|
||||||
|
|
||||||
@@ -94,23 +92,9 @@ import {
|
|||||||
MongoInvitesRepository,
|
MongoInvitesRepository,
|
||||||
MongoRecoveryTokensRepository,
|
MongoRecoveryTokensRepository,
|
||||||
MongoNotificationsRepository,
|
MongoNotificationsRepository,
|
||||||
MongoIncidentsRepository,
|
MongoIncidentRepository,
|
||||||
MongoTeamsRepository,
|
MongoTeamsRepository,
|
||||||
MongoMaintenanceWindowsRepository,
|
MongoMaintenanceWindowsRepository,
|
||||||
MongoSettingsRepository,
|
|
||||||
TimescaleMonitorsRepository,
|
|
||||||
TimescaleChecksRepository,
|
|
||||||
TimescaleGeoChecksRepository,
|
|
||||||
TimescaleMonitorStatsRepository,
|
|
||||||
TimescaleStatusPagesRepository,
|
|
||||||
TimescaleUsersRepository,
|
|
||||||
TimescaleInvitesRepository,
|
|
||||||
TimescaleRecoveryTokensRepository,
|
|
||||||
TimescaleNotificationsRepository,
|
|
||||||
TimescaleIncidentsRepository,
|
|
||||||
TimescaleTeamsRepository,
|
|
||||||
TimescaleMaintenanceWindowsRepository,
|
|
||||||
TimescaleSettingsRepository,
|
|
||||||
IMonitorsRepository,
|
IMonitorsRepository,
|
||||||
IChecksRepository,
|
IChecksRepository,
|
||||||
IGeoChecksRepository,
|
IGeoChecksRepository,
|
||||||
@@ -126,8 +110,6 @@ import {
|
|||||||
IMaintenanceWindowsRepository,
|
IMaintenanceWindowsRepository,
|
||||||
} from "@/repositories/index.js";
|
} from "@/repositories/index.js";
|
||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import TimescaleDB from "@/db/TimescaleDB.js";
|
|
||||||
import { AppError } from "@/utils/AppError.js";
|
|
||||||
|
|
||||||
export type InitializedServices = {
|
export type InitializedServices = {
|
||||||
settingsService: ISettingsService;
|
settingsService: ISettingsService;
|
||||||
@@ -170,81 +152,32 @@ export const initializeServices = async ({
|
|||||||
logger,
|
logger,
|
||||||
envSettings,
|
envSettings,
|
||||||
settingsService,
|
settingsService,
|
||||||
|
settingsRepository,
|
||||||
}: {
|
}: {
|
||||||
logger: ILogger;
|
logger: ILogger;
|
||||||
envSettings: EnvConfig;
|
envSettings: EnvConfig;
|
||||||
settingsService: ISettingsService;
|
settingsService: ISettingsService;
|
||||||
|
settingsRepository: ISettingsRepository;
|
||||||
}): Promise<InitializedServices> => {
|
}): Promise<InitializedServices> => {
|
||||||
// Create DB
|
// Create DB
|
||||||
|
|
||||||
const dbType = envSettings.dbType;
|
const db = new MongoDB(logger, envSettings);
|
||||||
|
|
||||||
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();
|
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
|
// Repositories
|
||||||
|
const monitorsRepository = new MongoMonitorsRepository();
|
||||||
if (dbType === "mongodb") {
|
const checksRepository = new MongoChecksRepository(logger);
|
||||||
monitorsRepository = new MongoMonitorsRepository();
|
const geoChecksRepository = new MongoGeoChecksRepository(logger);
|
||||||
checksRepository = new MongoChecksRepository(logger);
|
const monitorStatsRepository = new MongoMonitorStatsRepository();
|
||||||
geoChecksRepository = new MongoGeoChecksRepository(logger);
|
const statusPagesRepository = new MongoStatusPagesRepository();
|
||||||
monitorStatsRepository = new MongoMonitorStatsRepository();
|
const usersRepository = new MongoUsersRepository();
|
||||||
statusPagesRepository = new MongoStatusPagesRepository();
|
const invitesRepository = new MongoInvitesRepository();
|
||||||
usersRepository = new MongoUsersRepository();
|
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
|
||||||
invitesRepository = new MongoInvitesRepository();
|
const notificationsRepository = new MongoNotificationsRepository();
|
||||||
recoveryTokensRepository = new MongoRecoveryTokensRepository();
|
const incidentsRepository = new MongoIncidentRepository();
|
||||||
settingsRepository = new MongoSettingsRepository();
|
const teamsRepository = new MongoTeamsRepository();
|
||||||
notificationsRepository = new MongoNotificationsRepository();
|
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
|
||||||
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
|
// Network providers
|
||||||
const pingProvider = new PingProvider(ping);
|
const pingProvider = new PingProvider(ping);
|
||||||
@@ -297,7 +230,6 @@ export const initializeServices = async ({
|
|||||||
const pagerDutyProvider = new PagerDutyProvider(logger);
|
const pagerDutyProvider = new PagerDutyProvider(logger);
|
||||||
const matrixProvider = new MatrixProvider(logger);
|
const matrixProvider = new MatrixProvider(logger);
|
||||||
const teamsProvider = new TeamsProvider(logger);
|
const teamsProvider = new TeamsProvider(logger);
|
||||||
const telegramProvider = new TelegramProvider(logger);
|
|
||||||
|
|
||||||
const notificationsService = new NotificationsService(
|
const notificationsService = new NotificationsService(
|
||||||
notificationsRepository,
|
notificationsRepository,
|
||||||
@@ -309,7 +241,6 @@ export const initializeServices = async ({
|
|||||||
pagerDutyProvider,
|
pagerDutyProvider,
|
||||||
matrixProvider,
|
matrixProvider,
|
||||||
teamsProvider,
|
teamsProvider,
|
||||||
telegramProvider,
|
|
||||||
settingsService,
|
settingsService,
|
||||||
logger,
|
logger,
|
||||||
notificationMessageBuilder
|
notificationMessageBuilder
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ class GeoCheckController implements IGeoCheckController {
|
|||||||
const validatedParams = getChecksParamValidation.parse(req.params);
|
const validatedParams = getChecksParamValidation.parse(req.params);
|
||||||
const validatedQuery = getChecksQueryValidation.parse(req.query);
|
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 teamId = requireTeamId(req.user?.teamId);
|
||||||
|
|
||||||
const result = await this.geoChecksService.getGeoChecksByMonitor({
|
const result = await this.geoChecksService.getGeoChecksByMonitor({
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
export interface IDb {
|
export interface IDb {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
getPool(): Pool | null;
|
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js";
|
|||||||
import { EnvConfig } from "@/service/system/settingsService.js";
|
import { EnvConfig } from "@/service/system/settingsService.js";
|
||||||
const SERVICE_NAME = "MongoDB";
|
const SERVICE_NAME = "MongoDB";
|
||||||
import { IDb } from "@/db/IDb.js";
|
import { IDb } from "@/db/IDb.js";
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
class MongoDB implements IDb {
|
class MongoDB implements IDb {
|
||||||
static SERVICE_NAME = SERVICE_NAME;
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
@@ -75,10 +74,6 @@ class MongoDB implements IDb {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
getPool = () => {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MongoDB;
|
export default MongoDB;
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { IDb } from "@/db/IDb.js";
|
|
||||||
import { ILogger } from "@/utils/logger.js";
|
|
||||||
import { EnvConfig } from "@/service/system/settingsService.js";
|
|
||||||
import { Client, Pool } from "pg";
|
|
||||||
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
|
|
||||||
|
|
||||||
const SERVICE_NAME = "TimescaleDB";
|
|
||||||
|
|
||||||
class TimescaleDB implements IDb {
|
|
||||||
private pool: Pool | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private logger: ILogger,
|
|
||||||
private envSettings: EnvConfig
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private ensureDatabaseExists = async (connectionString: string) => {
|
|
||||||
const url = new URL(connectionString);
|
|
||||||
const dbName = url.pathname.slice(1); // Remove leading "/"
|
|
||||||
|
|
||||||
// Connect to the default "postgres" database
|
|
||||||
url.pathname = "/postgres";
|
|
||||||
const client = new Client({ connectionString: url.toString() });
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
await client.query(`CREATE DATABASE "${dbName}"`);
|
|
||||||
this.logger.info({
|
|
||||||
message: `Created database "${dbName}"`,
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "ensureDatabaseExists",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await client.end();
|
|
||||||
};
|
|
||||||
|
|
||||||
connect = async () => {
|
|
||||||
try {
|
|
||||||
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
|
|
||||||
|
|
||||||
await this.ensureDatabaseExists(connectionString);
|
|
||||||
|
|
||||||
this.pool = new Pool({ connectionString });
|
|
||||||
|
|
||||||
const client = await this.pool.connect();
|
|
||||||
client.release();
|
|
||||||
|
|
||||||
// Ensure TimescaleDB extension is enabled
|
|
||||||
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
|
|
||||||
|
|
||||||
this.logger.info({
|
|
||||||
message: "Connected to TimescaleDB",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "connect",
|
|
||||||
});
|
|
||||||
|
|
||||||
await runTimescaleDBMigrations(this.pool, this.logger);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error({
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "connect",
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
disconnect = async () => {
|
|
||||||
try {
|
|
||||||
if (this.pool === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.info({
|
|
||||||
message: "Disconnecting from TimescaleDB",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "disconnect",
|
|
||||||
});
|
|
||||||
await this.pool.end();
|
|
||||||
this.pool = null;
|
|
||||||
this.logger.info({
|
|
||||||
message: "Disconnected from TimescaleDB",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "disconnect",
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error({
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
method: "disconnect",
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getPool = (): Pool => {
|
|
||||||
if (this.pool === null) {
|
|
||||||
throw new Error("TimescaleDB is not connected. Call connect() first.");
|
|
||||||
}
|
|
||||||
return this.pool;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TimescaleDB;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createEnums = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropEnums = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
DROP TYPE IF EXISTS match_method;
|
|
||||||
DROP TYPE IF EXISTS status_page_type;
|
|
||||||
DROP TYPE IF EXISTS duration_unit;
|
|
||||||
DROP TYPE IF EXISTS incident_resolution_type;
|
|
||||||
DROP TYPE IF EXISTS user_role;
|
|
||||||
DROP TYPE IF EXISTS notification_type;
|
|
||||||
DROP TYPE IF EXISTS monitor_status;
|
|
||||||
DROP TYPE IF EXISTS monitor_type;
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createTeams = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS teams (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropTeams = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS teams;`);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createUsers = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
|
||||||
first_name TEXT NOT NULL,
|
|
||||||
last_name TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password TEXT NOT NULL,
|
|
||||||
avatar_image TEXT,
|
|
||||||
profile_image BYTEA,
|
|
||||||
profile_image_content_type TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
|
|
||||||
check_ttl INTEGER,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropUsers = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS users;`);
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createMonitors = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS monitors (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
team_id UUID NOT NULL REFERENCES teams(id),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
type monitor_type NOT NULL,
|
|
||||||
status monitor_status NOT NULL DEFAULT 'initializing',
|
|
||||||
|
|
||||||
-- HTTP / Website monitoring
|
|
||||||
url TEXT,
|
|
||||||
port INTEGER,
|
|
||||||
ignore_tls_errors BOOLEAN DEFAULT FALSE,
|
|
||||||
use_advanced_matching BOOLEAN DEFAULT FALSE,
|
|
||||||
json_path TEXT,
|
|
||||||
expected_value TEXT,
|
|
||||||
match_method match_method,
|
|
||||||
secret TEXT,
|
|
||||||
|
|
||||||
-- Scheduling
|
|
||||||
interval_ms INTEGER NOT NULL DEFAULT 60000,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Uptime tracking
|
|
||||||
status_window BOOLEAN[],
|
|
||||||
status_window_size INTEGER DEFAULT 5,
|
|
||||||
status_window_threshold INTEGER DEFAULT 60,
|
|
||||||
uptime_percentage DOUBLE PRECISION,
|
|
||||||
|
|
||||||
-- Infrastructure alert thresholds
|
|
||||||
cpu_alert_threshold INTEGER,
|
|
||||||
cpu_alert_counter INTEGER,
|
|
||||||
memory_alert_threshold INTEGER,
|
|
||||||
memory_alert_counter INTEGER,
|
|
||||||
disk_alert_threshold INTEGER,
|
|
||||||
disk_alert_counter INTEGER,
|
|
||||||
temp_alert_threshold INTEGER,
|
|
||||||
temp_alert_counter INTEGER,
|
|
||||||
selected_disks TEXT[],
|
|
||||||
|
|
||||||
-- Game monitoring
|
|
||||||
game_id TEXT,
|
|
||||||
grpc_service_name TEXT,
|
|
||||||
|
|
||||||
-- Grouping
|
|
||||||
monitor_group TEXT,
|
|
||||||
|
|
||||||
-- Geo checks
|
|
||||||
geo_check_enabled BOOLEAN DEFAULT FALSE,
|
|
||||||
geo_check_locations TEXT[],
|
|
||||||
geo_check_interval_ms INTEGER DEFAULT 300000,
|
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropMonitors = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS monitors;`);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createNotifications = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS notifications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
team_id UUID NOT NULL REFERENCES teams(id),
|
|
||||||
type notification_type NOT NULL,
|
|
||||||
notification_name TEXT,
|
|
||||||
address TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
|
|
||||||
-- Matrix-specific fields
|
|
||||||
homeserver_url TEXT,
|
|
||||||
room_id TEXT,
|
|
||||||
access_token TEXT,
|
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropNotifications = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS notifications;`);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createMonitorNotifications = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS monitor_notifications (
|
|
||||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (monitor_id, notification_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification
|
|
||||||
ON monitor_notifications (notification_id);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropMonitorNotifications = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createIncidents = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS incidents (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
start_time TIMESTAMPTZ NOT NULL,
|
|
||||||
end_time TIMESTAMPTZ,
|
|
||||||
status BOOLEAN,
|
|
||||||
message TEXT,
|
|
||||||
status_code INTEGER,
|
|
||||||
resolution_type incident_resolution_type,
|
|
||||||
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
resolved_by_email TEXT,
|
|
||||||
comment TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropIncidents = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS incidents;`);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createMonitorStats = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS monitor_stats (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
avg_response_time DOUBLE PRECISION DEFAULT 0,
|
|
||||||
max_response_time DOUBLE PRECISION DEFAULT 0,
|
|
||||||
total_checks BIGINT DEFAULT 0,
|
|
||||||
total_up_checks BIGINT DEFAULT 0,
|
|
||||||
total_down_checks BIGINT DEFAULT 0,
|
|
||||||
uptime_percentage DOUBLE PRECISION DEFAULT 0,
|
|
||||||
last_check_timestamp TIMESTAMPTZ,
|
|
||||||
last_response_time DOUBLE PRECISION DEFAULT 0,
|
|
||||||
time_of_last_failure TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropMonitorStats = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS monitor_stats;`);
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createChecks = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS checks (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
monitor_id UUID NOT NULL,
|
|
||||||
team_id UUID NOT NULL,
|
|
||||||
monitor_type monitor_type NOT NULL,
|
|
||||||
status BOOLEAN,
|
|
||||||
response_time INTEGER,
|
|
||||||
status_code INTEGER,
|
|
||||||
message TEXT,
|
|
||||||
|
|
||||||
-- HTTP request timings
|
|
||||||
timing_start DOUBLE PRECISION,
|
|
||||||
timing_socket DOUBLE PRECISION,
|
|
||||||
timing_lookup DOUBLE PRECISION,
|
|
||||||
timing_connect DOUBLE PRECISION,
|
|
||||||
timing_secure_connect DOUBLE PRECISION,
|
|
||||||
timing_upload DOUBLE PRECISION,
|
|
||||||
timing_response DOUBLE PRECISION,
|
|
||||||
timing_end DOUBLE PRECISION,
|
|
||||||
phase_wait DOUBLE PRECISION,
|
|
||||||
phase_dns DOUBLE PRECISION,
|
|
||||||
phase_tcp DOUBLE PRECISION,
|
|
||||||
phase_tls DOUBLE PRECISION,
|
|
||||||
phase_request DOUBLE PRECISION,
|
|
||||||
phase_first_byte DOUBLE PRECISION,
|
|
||||||
phase_download DOUBLE PRECISION,
|
|
||||||
phase_total DOUBLE PRECISION,
|
|
||||||
|
|
||||||
-- CPU metrics
|
|
||||||
cpu_physical_core INTEGER,
|
|
||||||
cpu_logical_core INTEGER,
|
|
||||||
cpu_frequency DOUBLE PRECISION,
|
|
||||||
cpu_current_frequency DOUBLE PRECISION,
|
|
||||||
cpu_temperature DOUBLE PRECISION[],
|
|
||||||
cpu_free_percent DOUBLE PRECISION,
|
|
||||||
cpu_usage_percent DOUBLE PRECISION,
|
|
||||||
|
|
||||||
-- Memory metrics
|
|
||||||
mem_total_bytes BIGINT,
|
|
||||||
mem_available_bytes BIGINT,
|
|
||||||
mem_used_bytes BIGINT,
|
|
||||||
mem_usage_percent DOUBLE PRECISION,
|
|
||||||
|
|
||||||
-- Host info
|
|
||||||
host_os TEXT,
|
|
||||||
host_platform TEXT,
|
|
||||||
host_kernel_version TEXT,
|
|
||||||
host_pretty_name TEXT,
|
|
||||||
|
|
||||||
-- Capture agent info
|
|
||||||
capture_version TEXT,
|
|
||||||
capture_mode TEXT,
|
|
||||||
|
|
||||||
-- Lighthouse / PageSpeed scores
|
|
||||||
lighthouse_performance DOUBLE PRECISION,
|
|
||||||
lighthouse_accessibility DOUBLE PRECISION,
|
|
||||||
lighthouse_best_practices DOUBLE PRECISION,
|
|
||||||
lighthouse_seo DOUBLE PRECISION,
|
|
||||||
|
|
||||||
-- Lighthouse audit details
|
|
||||||
audit_cls_score DOUBLE PRECISION,
|
|
||||||
audit_cls_value DOUBLE PRECISION,
|
|
||||||
audit_cls_display TEXT,
|
|
||||||
audit_si_score DOUBLE PRECISION,
|
|
||||||
audit_si_value DOUBLE PRECISION,
|
|
||||||
audit_si_display TEXT,
|
|
||||||
audit_fcp_score DOUBLE PRECISION,
|
|
||||||
audit_fcp_value DOUBLE PRECISION,
|
|
||||||
audit_fcp_display TEXT,
|
|
||||||
audit_lcp_score DOUBLE PRECISION,
|
|
||||||
audit_lcp_value DOUBLE PRECISION,
|
|
||||||
audit_lcp_display TEXT,
|
|
||||||
audit_tbt_score DOUBLE PRECISION,
|
|
||||||
audit_tbt_value DOUBLE PRECISION,
|
|
||||||
audit_tbt_display TEXT,
|
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('checks', 'created_at',
|
|
||||||
chunk_time_interval => INTERVAL '7 days',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
|
|
||||||
ON checks (monitor_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_team_time
|
|
||||||
ON checks (team_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time
|
|
||||||
ON checks (monitor_id, monitor_type, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_team_status_time
|
|
||||||
ON checks (team_id, status, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checks_status_code
|
|
||||||
ON checks (status_code, created_at DESC);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropChecks = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS checks;`);
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createCheckDisks = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS check_disks (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
check_id UUID NOT NULL,
|
|
||||||
check_created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
device TEXT,
|
|
||||||
mountpoint TEXT,
|
|
||||||
total_bytes BIGINT,
|
|
||||||
free_bytes BIGINT,
|
|
||||||
used_bytes BIGINT,
|
|
||||||
usage_percent DOUBLE PRECISION,
|
|
||||||
total_inodes BIGINT,
|
|
||||||
free_inodes BIGINT,
|
|
||||||
used_inodes BIGINT,
|
|
||||||
inodes_usage_percent DOUBLE PRECISION,
|
|
||||||
read_bytes BIGINT,
|
|
||||||
write_bytes BIGINT,
|
|
||||||
read_time BIGINT,
|
|
||||||
write_time BIGINT
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('check_disks', 'check_created_at',
|
|
||||||
chunk_time_interval => INTERVAL '7 days',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_check_disks_check
|
|
||||||
ON check_disks (check_id, check_created_at);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropCheckDisks = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS check_disks;`);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createCheckNetworkInterfaces = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS check_network_interfaces (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
check_id UUID NOT NULL,
|
|
||||||
check_created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
name TEXT,
|
|
||||||
bytes_sent BIGINT,
|
|
||||||
bytes_recv BIGINT,
|
|
||||||
packets_sent BIGINT,
|
|
||||||
packets_recv BIGINT,
|
|
||||||
err_in BIGINT,
|
|
||||||
err_out BIGINT,
|
|
||||||
drop_in BIGINT,
|
|
||||||
drop_out BIGINT,
|
|
||||||
fifo_in BIGINT,
|
|
||||||
fifo_out BIGINT
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('check_network_interfaces', 'check_created_at',
|
|
||||||
chunk_time_interval => INTERVAL '7 days',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_check_net_check
|
|
||||||
ON check_network_interfaces (check_id, check_created_at);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropCheckNetworkInterfaces = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`);
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createCheckErrors = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS check_errors (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
check_id UUID NOT NULL,
|
|
||||||
check_created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
metrics TEXT[],
|
|
||||||
error TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('check_errors', 'check_created_at',
|
|
||||||
chunk_time_interval => INTERVAL '7 days',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_check_errors_check
|
|
||||||
ON check_errors (check_id, check_created_at);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropCheckErrors = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS check_errors;`);
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createGeoChecks = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS geo_checks (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
monitor_id UUID NOT NULL,
|
|
||||||
team_id UUID NOT NULL,
|
|
||||||
monitor_type monitor_type NOT NULL,
|
|
||||||
expiry TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('geo_checks', 'created_at',
|
|
||||||
chunk_time_interval => INTERVAL '7 days',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time
|
|
||||||
ON geo_checks (monitor_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time
|
|
||||||
ON geo_checks (team_id, created_at DESC);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropGeoChecks = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS geo_checks;`);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createGeoCheckResults = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS geo_check_results (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
geo_check_id UUID NOT NULL,
|
|
||||||
geo_check_created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
|
|
||||||
-- Status
|
|
||||||
status BOOLEAN,
|
|
||||||
status_code INTEGER,
|
|
||||||
|
|
||||||
-- Location
|
|
||||||
location_continent TEXT,
|
|
||||||
location_region TEXT,
|
|
||||||
location_country TEXT,
|
|
||||||
location_state TEXT,
|
|
||||||
location_city TEXT,
|
|
||||||
location_longitude DOUBLE PRECISION,
|
|
||||||
location_latitude DOUBLE PRECISION,
|
|
||||||
|
|
||||||
-- Timings
|
|
||||||
timing_total DOUBLE PRECISION,
|
|
||||||
timing_dns DOUBLE PRECISION,
|
|
||||||
timing_tcp DOUBLE PRECISION,
|
|
||||||
timing_tls DOUBLE PRECISION,
|
|
||||||
timing_first_byte DOUBLE PRECISION,
|
|
||||||
timing_download DOUBLE PRECISION
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT create_hypertable('geo_check_results', 'geo_check_created_at',
|
|
||||||
chunk_time_interval => INTERVAL '7 days',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_geo_results_check
|
|
||||||
ON geo_check_results (geo_check_id, geo_check_created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_geo_results_country
|
|
||||||
ON geo_check_results (location_country, geo_check_created_at DESC);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropGeoCheckResults = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS geo_check_results;`);
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createInvites = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS invites (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
|
|
||||||
token TEXT NOT NULL,
|
|
||||||
expiry TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropInvites = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS invites;`);
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createRecoveryTokens = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS recovery_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
token TEXT NOT NULL,
|
|
||||||
expiry TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropRecoveryTokens = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`);
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createMaintenanceWindows = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS maintenance_windows (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
name TEXT,
|
|
||||||
duration INTEGER,
|
|
||||||
duration_unit duration_unit,
|
|
||||||
repeat INTEGER,
|
|
||||||
start_time TIMESTAMPTZ,
|
|
||||||
end_time TIMESTAMPTZ,
|
|
||||||
expiry TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropMaintenanceWindows = async (pool: Pool) => {
|
|
||||||
await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createStatusPages = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS status_pages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
team_id UUID NOT NULL REFERENCES teams(id),
|
|
||||||
types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[],
|
|
||||||
company_name TEXT,
|
|
||||||
url TEXT NOT NULL UNIQUE,
|
|
||||||
timezone TEXT,
|
|
||||||
color TEXT DEFAULT '#4169E1',
|
|
||||||
logo_data BYTEA,
|
|
||||||
logo_content_type TEXT,
|
|
||||||
is_published BOOLEAN DEFAULT FALSE,
|
|
||||||
show_charts BOOLEAN DEFAULT FALSE,
|
|
||||||
show_uptime_percentage BOOLEAN DEFAULT FALSE,
|
|
||||||
show_admin_login_link BOOLEAN DEFAULT FALSE,
|
|
||||||
show_infrastructure BOOLEAN DEFAULT FALSE,
|
|
||||||
custom_css TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS status_page_monitors (
|
|
||||||
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
|
||||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (status_page_id, monitor_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS status_page_sub_monitors (
|
|
||||||
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
|
|
||||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (status_page_id, monitor_id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropStatusPages = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
DROP TABLE IF EXISTS status_page_sub_monitors;
|
|
||||||
DROP TABLE IF EXISTS status_page_monitors;
|
|
||||||
DROP TABLE IF EXISTS status_pages;
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createAppSettings = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
check_ttl INTEGER DEFAULT 30,
|
|
||||||
language TEXT,
|
|
||||||
jwt_secret TEXT,
|
|
||||||
pagespeed_api_key TEXT,
|
|
||||||
|
|
||||||
-- SMTP / Email settings
|
|
||||||
system_email_host TEXT,
|
|
||||||
system_email_port INTEGER,
|
|
||||||
system_email_address TEXT,
|
|
||||||
system_email_password TEXT,
|
|
||||||
system_email_user TEXT,
|
|
||||||
system_email_connection_host TEXT,
|
|
||||||
system_email_tls_servername TEXT,
|
|
||||||
system_email_secure BOOLEAN,
|
|
||||||
system_email_pool BOOLEAN,
|
|
||||||
system_email_ignore_tls BOOLEAN,
|
|
||||||
system_email_require_tls BOOLEAN,
|
|
||||||
system_email_reject_unauthorized BOOLEAN,
|
|
||||||
|
|
||||||
show_url BOOLEAN,
|
|
||||||
version TEXT,
|
|
||||||
|
|
||||||
-- Global infrastructure thresholds
|
|
||||||
threshold_cpu_usage INTEGER,
|
|
||||||
threshold_memory_usage INTEGER,
|
|
||||||
threshold_disk_usage INTEGER,
|
|
||||||
threshold_temperature INTEGER,
|
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Enforce singleton: only one row allowed
|
|
||||||
CREATE OR REPLACE FUNCTION enforce_singleton_app_settings()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN
|
|
||||||
RAISE EXCEPTION 'app_settings allows only one row';
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings;
|
|
||||||
CREATE TRIGGER trg_singleton_app_settings
|
|
||||||
BEFORE INSERT ON app_settings
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings();
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropAppSettings = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
DROP TABLE IF EXISTS app_settings;
|
|
||||||
DROP FUNCTION IF EXISTS enforce_singleton_app_settings();
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createContinuousAggregates = async (pool: Pool) => {
|
|
||||||
// Uptime & Response Time (Hourly)
|
|
||||||
await pool.query(`
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly
|
|
||||||
WITH (timescaledb.continuous) AS
|
|
||||||
SELECT
|
|
||||||
monitor_id,
|
|
||||||
time_bucket('1 hour', created_at) AS bucket,
|
|
||||||
COUNT(*) AS total_checks,
|
|
||||||
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
|
|
||||||
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
|
|
||||||
AVG(response_time) AS avg_response_time,
|
|
||||||
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
|
|
||||||
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
|
|
||||||
MAX(response_time) AS max_response_time,
|
|
||||||
MIN(response_time) AS min_response_time
|
|
||||||
FROM checks
|
|
||||||
GROUP BY monitor_id, bucket
|
|
||||||
WITH NO DATA;
|
|
||||||
`);
|
|
||||||
await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`);
|
|
||||||
await pool.query(`
|
|
||||||
SELECT add_continuous_aggregate_policy('checks_hourly',
|
|
||||||
start_offset => INTERVAL '3 hours',
|
|
||||||
end_offset => INTERVAL '1 hour',
|
|
||||||
schedule_interval => INTERVAL '1 hour',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`);
|
|
||||||
|
|
||||||
// Uptime & Response Time (Daily)
|
|
||||||
await pool.query(`
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily
|
|
||||||
WITH (timescaledb.continuous) AS
|
|
||||||
SELECT
|
|
||||||
monitor_id,
|
|
||||||
time_bucket('1 day', created_at) AS bucket,
|
|
||||||
COUNT(*) AS total_checks,
|
|
||||||
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
|
|
||||||
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
|
|
||||||
AVG(response_time) AS avg_response_time,
|
|
||||||
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
|
|
||||||
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
|
|
||||||
MAX(response_time) AS max_response_time
|
|
||||||
FROM checks
|
|
||||||
GROUP BY monitor_id, bucket
|
|
||||||
WITH NO DATA;
|
|
||||||
`);
|
|
||||||
await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`);
|
|
||||||
await pool.query(`
|
|
||||||
SELECT add_continuous_aggregate_policy('checks_daily',
|
|
||||||
start_offset => INTERVAL '3 days',
|
|
||||||
end_offset => INTERVAL '1 day',
|
|
||||||
schedule_interval => INTERVAL '1 day',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`);
|
|
||||||
|
|
||||||
// Infrastructure Metrics (Hourly)
|
|
||||||
await pool.query(`
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly
|
|
||||||
WITH (timescaledb.continuous) AS
|
|
||||||
SELECT
|
|
||||||
monitor_id,
|
|
||||||
time_bucket('1 hour', created_at) AS bucket,
|
|
||||||
AVG(cpu_usage_percent) AS avg_cpu,
|
|
||||||
MAX(cpu_usage_percent) AS max_cpu,
|
|
||||||
AVG(mem_usage_percent) AS avg_memory,
|
|
||||||
MAX(mem_usage_percent) AS max_memory,
|
|
||||||
COUNT(*) AS sample_count
|
|
||||||
FROM checks
|
|
||||||
WHERE monitor_type = 'hardware'
|
|
||||||
GROUP BY monitor_id, bucket
|
|
||||||
WITH NO DATA;
|
|
||||||
`);
|
|
||||||
await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`);
|
|
||||||
await pool.query(`
|
|
||||||
SELECT add_continuous_aggregate_policy('hardware_hourly',
|
|
||||||
start_offset => INTERVAL '3 hours',
|
|
||||||
end_offset => INTERVAL '1 hour',
|
|
||||||
schedule_interval => INTERVAL '1 hour',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`);
|
|
||||||
|
|
||||||
// PageSpeed Scores (Daily)
|
|
||||||
await pool.query(`
|
|
||||||
CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily
|
|
||||||
WITH (timescaledb.continuous) AS
|
|
||||||
SELECT
|
|
||||||
monitor_id,
|
|
||||||
time_bucket('1 day', created_at) AS bucket,
|
|
||||||
AVG(lighthouse_performance) AS avg_performance,
|
|
||||||
AVG(lighthouse_accessibility) AS avg_accessibility,
|
|
||||||
AVG(lighthouse_best_practices) AS avg_best_practices,
|
|
||||||
AVG(lighthouse_seo) AS avg_seo,
|
|
||||||
COUNT(*) AS sample_count
|
|
||||||
FROM checks
|
|
||||||
WHERE monitor_type = 'pagespeed'
|
|
||||||
GROUP BY monitor_id, bucket
|
|
||||||
WITH NO DATA;
|
|
||||||
`);
|
|
||||||
await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`);
|
|
||||||
await pool.query(`
|
|
||||||
SELECT add_continuous_aggregate_policy('pagespeed_daily',
|
|
||||||
start_offset => INTERVAL '3 days',
|
|
||||||
end_offset => INTERVAL '1 day',
|
|
||||||
schedule_interval => INTERVAL '1 day',
|
|
||||||
if_not_exists => TRUE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropContinuousAggregates = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE;
|
|
||||||
DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE;
|
|
||||||
DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE;
|
|
||||||
DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE;
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export const createRetentionCompression = async (pool: Pool) => {
|
|
||||||
// -- Compression policies --
|
|
||||||
|
|
||||||
// Checks
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE checks SET (
|
|
||||||
timescaledb.compress,
|
|
||||||
timescaledb.compress_segmentby = 'monitor_id',
|
|
||||||
timescaledb.compress_orderby = 'created_at DESC'
|
|
||||||
);
|
|
||||||
SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Check child tables
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE check_disks SET (
|
|
||||||
timescaledb.compress,
|
|
||||||
timescaledb.compress_segmentby = 'check_id',
|
|
||||||
timescaledb.compress_orderby = 'check_created_at DESC'
|
|
||||||
);
|
|
||||||
SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE check_network_interfaces SET (
|
|
||||||
timescaledb.compress,
|
|
||||||
timescaledb.compress_segmentby = 'check_id',
|
|
||||||
timescaledb.compress_orderby = 'check_created_at DESC'
|
|
||||||
);
|
|
||||||
SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE check_errors SET (
|
|
||||||
timescaledb.compress,
|
|
||||||
timescaledb.compress_segmentby = 'check_id',
|
|
||||||
timescaledb.compress_orderby = 'check_created_at DESC'
|
|
||||||
);
|
|
||||||
SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Geo checks
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE geo_checks SET (
|
|
||||||
timescaledb.compress,
|
|
||||||
timescaledb.compress_segmentby = 'monitor_id',
|
|
||||||
timescaledb.compress_orderby = 'created_at DESC'
|
|
||||||
);
|
|
||||||
SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE geo_check_results SET (
|
|
||||||
timescaledb.compress,
|
|
||||||
timescaledb.compress_segmentby = 'geo_check_id',
|
|
||||||
timescaledb.compress_orderby = 'geo_check_created_at DESC'
|
|
||||||
);
|
|
||||||
SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// -- Retention policies (default 30 days) --
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE);
|
|
||||||
SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE);
|
|
||||||
SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE);
|
|
||||||
SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE);
|
|
||||||
SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE);
|
|
||||||
SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// -- Dynamic retention helper function --
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
ttl_interval INTERVAL;
|
|
||||||
BEGIN
|
|
||||||
ttl_interval := (new_ttl_days || ' days')::INTERVAL;
|
|
||||||
|
|
||||||
PERFORM remove_retention_policy('checks', if_exists => true);
|
|
||||||
PERFORM remove_retention_policy('check_disks', if_exists => true);
|
|
||||||
PERFORM remove_retention_policy('check_network_interfaces', if_exists => true);
|
|
||||||
PERFORM remove_retention_policy('check_errors', if_exists => true);
|
|
||||||
PERFORM remove_retention_policy('geo_checks', if_exists => true);
|
|
||||||
PERFORM remove_retention_policy('geo_check_results', if_exists => true);
|
|
||||||
|
|
||||||
PERFORM add_retention_policy('checks', ttl_interval);
|
|
||||||
PERFORM add_retention_policy('check_disks', ttl_interval);
|
|
||||||
PERFORM add_retention_policy('check_network_interfaces', ttl_interval);
|
|
||||||
PERFORM add_retention_policy('check_errors', ttl_interval);
|
|
||||||
PERFORM add_retention_policy('geo_checks', ttl_interval);
|
|
||||||
PERFORM add_retention_policy('geo_check_results', ttl_interval);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dropRetentionCompression = async (pool: Pool) => {
|
|
||||||
// Remove retention policies
|
|
||||||
await pool.query(`
|
|
||||||
SELECT remove_retention_policy('checks', if_exists => true);
|
|
||||||
SELECT remove_retention_policy('check_disks', if_exists => true);
|
|
||||||
SELECT remove_retention_policy('check_network_interfaces', if_exists => true);
|
|
||||||
SELECT remove_retention_policy('check_errors', if_exists => true);
|
|
||||||
SELECT remove_retention_policy('geo_checks', if_exists => true);
|
|
||||||
SELECT remove_retention_policy('geo_check_results', if_exists => true);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Remove compression policies
|
|
||||||
await pool.query(`
|
|
||||||
SELECT remove_compression_policy('checks', if_exists => true);
|
|
||||||
SELECT remove_compression_policy('check_disks', if_exists => true);
|
|
||||||
SELECT remove_compression_policy('check_network_interfaces', if_exists => true);
|
|
||||||
SELECT remove_compression_policy('check_errors', if_exists => true);
|
|
||||||
SELECT remove_compression_policy('geo_checks', if_exists => true);
|
|
||||||
SELECT remove_compression_policy('geo_check_results', if_exists => true);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Disable compression on tables
|
|
||||||
await pool.query(`
|
|
||||||
ALTER TABLE checks SET (timescaledb.compress = false);
|
|
||||||
ALTER TABLE check_disks SET (timescaledb.compress = false);
|
|
||||||
ALTER TABLE check_network_interfaces SET (timescaledb.compress = false);
|
|
||||||
ALTER TABLE check_errors SET (timescaledb.compress = false);
|
|
||||||
ALTER TABLE geo_checks SET (timescaledb.compress = false);
|
|
||||||
ALTER TABLE geo_check_results SET (timescaledb.compress = false);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Drop dynamic retention function
|
|
||||||
await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`);
|
|
||||||
};
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
import type { ILogger } from "@/utils/logger.js";
|
|
||||||
import { createEnums, dropEnums } from "./0001_create_enums.js";
|
|
||||||
import { createTeams, dropTeams } from "./0002_create_teams.js";
|
|
||||||
import { createUsers, dropUsers } from "./0003_create_users.js";
|
|
||||||
import { createMonitors, dropMonitors } from "./0004_create_monitors.js";
|
|
||||||
import { createNotifications, dropNotifications } from "./0005_create_notifications.js";
|
|
||||||
import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js";
|
|
||||||
import { createIncidents, dropIncidents } from "./0007_create_incidents.js";
|
|
||||||
import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js";
|
|
||||||
import { createChecks, dropChecks } from "./0009_create_checks.js";
|
|
||||||
import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js";
|
|
||||||
import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js";
|
|
||||||
import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js";
|
|
||||||
import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js";
|
|
||||||
import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js";
|
|
||||||
import { createInvites, dropInvites } from "./0015_create_invites.js";
|
|
||||||
import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js";
|
|
||||||
import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js";
|
|
||||||
import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js";
|
|
||||||
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
|
|
||||||
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
|
|
||||||
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
|
|
||||||
|
|
||||||
const SERVICE_NAME = "TimescaleDB Migrations";
|
|
||||||
|
|
||||||
type MigrationEntry = {
|
|
||||||
name: string;
|
|
||||||
up: (pool: Pool) => Promise<void>;
|
|
||||||
down: (pool: Pool) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrations: MigrationEntry[] = [
|
|
||||||
{ name: "0001_create_enums", up: createEnums, down: dropEnums },
|
|
||||||
{ name: "0002_create_teams", up: createTeams, down: dropTeams },
|
|
||||||
{ name: "0003_create_users", up: createUsers, down: dropUsers },
|
|
||||||
{ name: "0004_create_monitors", up: createMonitors, down: dropMonitors },
|
|
||||||
{ name: "0005_create_notifications", up: createNotifications, down: dropNotifications },
|
|
||||||
{ name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications },
|
|
||||||
{ name: "0007_create_incidents", up: createIncidents, down: dropIncidents },
|
|
||||||
{ name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats },
|
|
||||||
{ name: "0009_create_checks", up: createChecks, down: dropChecks },
|
|
||||||
{ name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks },
|
|
||||||
{ name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces },
|
|
||||||
{ name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors },
|
|
||||||
{ name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks },
|
|
||||||
{ name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults },
|
|
||||||
{ name: "0015_create_invites", up: createInvites, down: dropInvites },
|
|
||||||
{ name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens },
|
|
||||||
{ name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows },
|
|
||||||
{ name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages },
|
|
||||||
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
|
|
||||||
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
|
|
||||||
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ensureMigrationsTable = async (pool: Pool) => {
|
|
||||||
await pool.query(`
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE migration_status AS ENUM ('completed', 'failed');
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS migrations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
status migration_status NOT NULL,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
error TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => {
|
|
||||||
try {
|
|
||||||
logger?.info({ message: "Running migrations", service: SERVICE_NAME });
|
|
||||||
|
|
||||||
await ensureMigrationsTable(pool);
|
|
||||||
|
|
||||||
for (const migration of migrations) {
|
|
||||||
const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]);
|
|
||||||
if (result.rowCount !== null && result.rowCount > 0) {
|
|
||||||
logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await migration.up(pool);
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO migrations (name, status, completed_at)
|
|
||||||
VALUES ($1, 'completed', NOW())
|
|
||||||
ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`,
|
|
||||||
[migration.name]
|
|
||||||
);
|
|
||||||
logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME });
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO migrations (name, status, error)
|
|
||||||
VALUES ($1, 'failed', $2)
|
|
||||||
ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`,
|
|
||||||
[migration.name, err?.message]
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger?.info({ message: "Migrations completed", service: SERVICE_NAME });
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => {
|
|
||||||
try {
|
|
||||||
await ensureMigrationsTable(pool);
|
|
||||||
|
|
||||||
// Find the last completed migration
|
|
||||||
const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1");
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastMigrationName = result.rows[0].name;
|
|
||||||
const migration = migrations.find((m) => m.name === lastMigrationName);
|
|
||||||
if (!migration) {
|
|
||||||
throw new Error(`Migration "${lastMigrationName}" not found in migration list`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME });
|
|
||||||
await migration.down(pool);
|
|
||||||
await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]);
|
|
||||||
logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME });
|
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
|
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
notificationName: {
|
notificationName: {
|
||||||
|
|||||||
@@ -36,6 +36,3 @@ export { default as MaintenanceWindowModel } from "@/db/models/MaintenanceWindow
|
|||||||
|
|
||||||
export * from "@/db/models/GeoCheck.js";
|
export * from "@/db/models/GeoCheck.js";
|
||||||
export { default as GeoCheckModel } 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";
|
|
||||||
+12
-6
@@ -1,14 +1,16 @@
|
|||||||
import { initializeServices } from "./config/services.js";
|
import { initializeServices } from "./config/services.js";
|
||||||
import { initializeControllers } from "./config/controllers.js";
|
import { initializeControllers } from "./config/controllers.js";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { initShutdownListener } from "@/shutdown.js";
|
import { initShutdownListener } from "./shutdown.js";
|
||||||
import { validateEnv } from "@/validation/envValidation.js";
|
import { validateEnv } from "./validation/envValidation.js";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { runMigrations } from "./db/migration/index.js";
|
||||||
|
|
||||||
import Logger, { ILogger } from "@/utils/logger.js";
|
import Logger, { ILogger } from "@/utils/logger.js";
|
||||||
import { SettingsService } from "@/service/index.js";
|
import { SettingsService } from "@/service/index.js";
|
||||||
|
import { MongoSettingsRepository } from "./repositories/index.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "Server";
|
const SERVICE_NAME = "Server";
|
||||||
let logger: ILogger;
|
let logger: ILogger;
|
||||||
@@ -23,15 +25,19 @@ const startApp = async () => {
|
|||||||
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
|
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
|
||||||
const frontendPath = path.join(__dirname, "..", "public");
|
const frontendPath = path.join(__dirname, "..", "public");
|
||||||
|
|
||||||
// Create settings service (env only — DB repository injected after connect)
|
// Create services
|
||||||
const settingsService = new SettingsService(env);
|
const settingsRepository = new MongoSettingsRepository();
|
||||||
|
const settingsService = new SettingsService(settingsRepository, env);
|
||||||
|
|
||||||
const envSettings = settingsService.loadSettings();
|
const envSettings = settingsService.loadSettings();
|
||||||
|
|
||||||
// Create logger
|
// Create logger
|
||||||
logger = new Logger({ envSettings });
|
logger = new Logger({ envSettings });
|
||||||
|
|
||||||
// Initialize services (connects DB, creates repositories, injects settingsRepository)
|
// Initialize services
|
||||||
const services = await initializeServices({ logger, envSettings, settingsService });
|
const services = await initializeServices({ logger, envSettings, settingsService, settingsRepository });
|
||||||
|
|
||||||
|
await runMigrations(logger);
|
||||||
|
|
||||||
// Initialize controllers
|
// Initialize controllers
|
||||||
const controllers = initializeControllers(services);
|
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 handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
||||||
const status = error instanceof AppError ? error.status || 500 : 500;
|
const status = error instanceof AppError ? error.status || 500 : 500;
|
||||||
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
|
const message = error instanceof AppError ? error.message : "Server error";
|
||||||
const service = error instanceof AppError ? error.service : "unknownService";
|
const service = error instanceof AppError ? error.service : "unknownService";
|
||||||
const method = error instanceof AppError ? error.method : "unknownMethod";
|
const method = error instanceof AppError ? error.method : "unknownMethod";
|
||||||
logger.error({
|
logger.error({
|
||||||
message: message,
|
message: message,
|
||||||
service: service,
|
service: service,
|
||||||
method: method,
|
method: method,
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof AppError ? error.stack : undefined,
|
||||||
details: error instanceof AppError ? error.details : undefined,
|
details: error instanceof AppError ? error.details : undefined,
|
||||||
});
|
});
|
||||||
res.status(status).json({
|
res.status(status).json({
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,30 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
|
|
||||||
import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js";
|
|
||||||
import { getDateForRange } from "@/utils/dataUtils.js";
|
|
||||||
|
|
||||||
const dateStringToBucket = (dateString: string): string => {
|
|
||||||
if (dateString.includes("%M")) return "1 minute";
|
|
||||||
if (dateString.includes("%H")) return "1 hour";
|
|
||||||
return "1 day";
|
|
||||||
};
|
|
||||||
|
|
||||||
export class TimescaleGeoChecksRepository implements IGeoChecksRepository {
|
|
||||||
constructor(private pool: Pool) {}
|
|
||||||
|
|
||||||
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
|
|
||||||
const created: GeoCheck[] = [];
|
|
||||||
|
|
||||||
for (const geoCheck of geoChecks) {
|
|
||||||
const geoResult = await this.pool.query(
|
|
||||||
`INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`,
|
|
||||||
[geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null]
|
|
||||||
);
|
|
||||||
const row = geoResult.rows[0];
|
|
||||||
if (!row) continue;
|
|
||||||
|
|
||||||
for (const result of geoCheck.results) {
|
|
||||||
await this.pool.query(
|
|
||||||
`INSERT INTO geo_check_results (
|
|
||||||
geo_check_id, geo_check_created_at,
|
|
||||||
status, status_code,
|
|
||||||
location_continent, location_region, location_country, location_state, location_city,
|
|
||||||
location_longitude, location_latitude,
|
|
||||||
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
|
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
|
|
||||||
[
|
|
||||||
row.id,
|
|
||||||
row.created_at,
|
|
||||||
result.status,
|
|
||||||
result.statusCode,
|
|
||||||
result.location.continent,
|
|
||||||
result.location.region,
|
|
||||||
result.location.country,
|
|
||||||
result.location.state,
|
|
||||||
result.location.city,
|
|
||||||
result.location.longitude,
|
|
||||||
result.location.latitude,
|
|
||||||
result.timings.total,
|
|
||||||
result.timings.dns,
|
|
||||||
result.timings.tcp,
|
|
||||||
result.timings.tls,
|
|
||||||
result.timings.firstByte,
|
|
||||||
result.timings.download,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
created.push(this.toGeoCheck(row, geoCheck.results));
|
|
||||||
}
|
|
||||||
|
|
||||||
return created;
|
|
||||||
};
|
|
||||||
|
|
||||||
findByMonitorId = async (
|
|
||||||
monitorId: string,
|
|
||||||
sortOrder: string,
|
|
||||||
dateRange: string,
|
|
||||||
page: number,
|
|
||||||
rowsPerPage: number,
|
|
||||||
continents?: GeoContinent[]
|
|
||||||
): Promise<FlatGeoChecksQueryResult> => {
|
|
||||||
const conditions: string[] = ["gc.monitor_id = $1"];
|
|
||||||
const values: unknown[] = [monitorId];
|
|
||||||
let paramIndex = 2;
|
|
||||||
|
|
||||||
const rangeDate = getDateForRange(dateRange);
|
|
||||||
if (rangeDate) {
|
|
||||||
conditions.push(`gc.created_at >= $${paramIndex++}`);
|
|
||||||
values.push(rangeDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (continents && continents.length > 0) {
|
|
||||||
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
|
|
||||||
values.push(continents);
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.join(" AND ");
|
|
||||||
const direction = sortOrder === "asc" ? "ASC" : "DESC";
|
|
||||||
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
|
|
||||||
|
|
||||||
const countValues = [...values];
|
|
||||||
const dataValues = [...values, rowsPerPage, offset];
|
|
||||||
|
|
||||||
const [countResult, dataResult] = await Promise.all([
|
|
||||||
this.pool.query(
|
|
||||||
`SELECT COUNT(*)::int AS count
|
|
||||||
FROM geo_checks gc
|
|
||||||
JOIN geo_check_results r ON r.geo_check_id = gc.id
|
|
||||||
WHERE ${where}`,
|
|
||||||
countValues
|
|
||||||
),
|
|
||||||
this.pool.query(
|
|
||||||
`SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type,
|
|
||||||
r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city,
|
|
||||||
r.location_longitude, r.location_latitude,
|
|
||||||
r.status, r.status_code,
|
|
||||||
r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download,
|
|
||||||
gc.created_at, gc.updated_at
|
|
||||||
FROM geo_checks gc
|
|
||||||
JOIN geo_check_results r ON r.geo_check_id = gc.id
|
|
||||||
WHERE ${where}
|
|
||||||
ORDER BY gc.created_at ${direction}
|
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
|
||||||
dataValues
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({
|
|
||||||
id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`,
|
|
||||||
monitorId: row.monitor_id,
|
|
||||||
teamId: row.team_id,
|
|
||||||
type: row.monitor_type,
|
|
||||||
location: {
|
|
||||||
continent: row.location_continent,
|
|
||||||
region: row.location_region,
|
|
||||||
country: row.location_country,
|
|
||||||
state: row.location_state,
|
|
||||||
city: row.location_city,
|
|
||||||
longitude: row.location_longitude,
|
|
||||||
latitude: row.location_latitude,
|
|
||||||
},
|
|
||||||
status: row.status,
|
|
||||||
statusCode: row.status_code,
|
|
||||||
timings: {
|
|
||||||
total: row.timing_total ?? 0,
|
|
||||||
dns: row.timing_dns ?? 0,
|
|
||||||
tcp: row.timing_tcp ?? 0,
|
|
||||||
tls: row.timing_tls ?? 0,
|
|
||||||
firstByte: row.timing_first_byte ?? 0,
|
|
||||||
download: row.timing_download ?? 0,
|
|
||||||
},
|
|
||||||
createdAt: row.created_at.toISOString(),
|
|
||||||
updatedAt: row.updated_at.toISOString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { geoChecksCount: countResult.rows[0].count, geoChecks };
|
|
||||||
};
|
|
||||||
|
|
||||||
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
|
|
||||||
const geoRows = await this.pool.query(
|
|
||||||
`SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at
|
|
||||||
FROM geo_checks
|
|
||||||
WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
[monitorId, startDate, endDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const geoChecks: GeoCheck[] = [];
|
|
||||||
for (const row of geoRows.rows) {
|
|
||||||
const resultsRows = await this.pool.query(
|
|
||||||
`SELECT status, status_code,
|
|
||||||
location_continent, location_region, location_country, location_state, location_city,
|
|
||||||
location_longitude, location_latitude,
|
|
||||||
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
|
|
||||||
FROM geo_check_results
|
|
||||||
WHERE geo_check_id = $1`,
|
|
||||||
[row.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = resultsRows.rows.map(this.toGeoCheckResult);
|
|
||||||
geoChecks.push(this.toGeoCheck(row, results));
|
|
||||||
}
|
|
||||||
|
|
||||||
return geoChecks;
|
|
||||||
};
|
|
||||||
|
|
||||||
findGroupedByMonitorIdAndDateRange = async (
|
|
||||||
monitorId: string,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date,
|
|
||||||
dateFormat: string,
|
|
||||||
continents?: GeoContinent[]
|
|
||||||
): Promise<GroupedGeoCheck[]> => {
|
|
||||||
const bucket = dateStringToBucket(dateFormat);
|
|
||||||
|
|
||||||
const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"];
|
|
||||||
const values: unknown[] = [bucket, monitorId, startDate, endDate];
|
|
||||||
let paramIndex = 5;
|
|
||||||
|
|
||||||
if (continents && continents.length > 0) {
|
|
||||||
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
|
|
||||||
values.push(continents);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.pool.query(
|
|
||||||
`SELECT
|
|
||||||
time_bucket($1::interval, gc.created_at) AS bucket_date,
|
|
||||||
r.location_continent AS continent,
|
|
||||||
ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime",
|
|
||||||
COUNT(*)::int AS "totalChecks",
|
|
||||||
ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage"
|
|
||||||
FROM geo_checks gc
|
|
||||||
JOIN geo_check_results r ON r.geo_check_id = gc.id
|
|
||||||
WHERE ${conditions.join(" AND ")}
|
|
||||||
GROUP BY bucket_date, r.location_continent
|
|
||||||
ORDER BY bucket_date, r.location_continent`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows.map((row) => ({
|
|
||||||
bucketDate: (row.bucket_date as Date).toISOString(),
|
|
||||||
continent: row.continent as GeoContinent,
|
|
||||||
avgResponseTime: Number(row.avgResponseTime ?? 0),
|
|
||||||
totalChecks: Number(row.totalChecks ?? 0),
|
|
||||||
uptimePercentage: Number(row.uptimePercentage ?? 0),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteByMonitorId = async (monitorId: string): Promise<number> => {
|
|
||||||
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteByTeamId = async (teamId: string): Promise<number> => {
|
|
||||||
const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
|
|
||||||
if (!monitorIds.length) {
|
|
||||||
const result = await this.pool.query(`DELETE FROM geo_checks`);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
}
|
|
||||||
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
private toGeoCheckResult = (row: Record<string, unknown>): GeoCheckResult => ({
|
|
||||||
location: {
|
|
||||||
continent: (row.location_continent as GeoContinent) ?? "",
|
|
||||||
region: (row.location_region as string) ?? "",
|
|
||||||
country: (row.location_country as string) ?? "",
|
|
||||||
state: (row.location_state as string) ?? "",
|
|
||||||
city: (row.location_city as string) ?? "",
|
|
||||||
longitude: (row.location_longitude as number) ?? 0,
|
|
||||||
latitude: (row.location_latitude as number) ?? 0,
|
|
||||||
},
|
|
||||||
status: (row.status as boolean) ?? false,
|
|
||||||
statusCode: (row.status_code as number) ?? 0,
|
|
||||||
timings: {
|
|
||||||
total: (row.timing_total as number) ?? 0,
|
|
||||||
dns: (row.timing_dns as number) ?? 0,
|
|
||||||
tcp: (row.timing_tcp as number) ?? 0,
|
|
||||||
tls: (row.timing_tls as number) ?? 0,
|
|
||||||
firstByte: (row.timing_first_byte as number) ?? 0,
|
|
||||||
download: (row.timing_download as number) ?? 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
private toGeoCheck = (row: Record<string, unknown>, results: GeoCheckResult[]): GeoCheck => ({
|
|
||||||
id: row.id as string,
|
|
||||||
metadata: {
|
|
||||||
monitorId: row.monitor_id as string,
|
|
||||||
teamId: row.team_id as string,
|
|
||||||
type: row.monitor_type as MonitorType,
|
|
||||||
},
|
|
||||||
results,
|
|
||||||
expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(),
|
|
||||||
__v: 0,
|
|
||||||
createdAt: (row.created_at as Date).toISOString(),
|
|
||||||
updatedAt: (row.updated_at as Date).toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+2
-2
@@ -5,7 +5,7 @@ import type { IIncidentsRepository } from "@/repositories/index.js";
|
|||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
|
|
||||||
class MongoIncidentsRepository implements IIncidentsRepository {
|
class MongoIncidentRepository implements IIncidentsRepository {
|
||||||
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
|
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
@@ -288,4 +288,4 @@ class MongoIncidentsRepository implements IIncidentsRepository {
|
|||||||
return result.deletedCount ?? 0;
|
return result.deletedCount ?? 0;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export default MongoIncidentsRepository;
|
export default MongoIncidentRepository;
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js";
|
|
||||||
import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js";
|
|
||||||
import { AppError } from "@/utils/AppError.js";
|
|
||||||
|
|
||||||
interface IncidentRow {
|
|
||||||
id: string;
|
|
||||||
monitor_id: string;
|
|
||||||
team_id: string;
|
|
||||||
start_time: Date;
|
|
||||||
end_time: Date | null;
|
|
||||||
status: boolean;
|
|
||||||
message: string | null;
|
|
||||||
status_code: number | null;
|
|
||||||
resolution_type: IncidentResolutionType;
|
|
||||||
resolved_by: string | null;
|
|
||||||
resolved_by_email: string | null;
|
|
||||||
comment: string | null;
|
|
||||||
created_at: Date;
|
|
||||||
updated_at: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code,
|
|
||||||
resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`;
|
|
||||||
|
|
||||||
export class TimescaleIncidentsRepository implements IIncidentsRepository {
|
|
||||||
constructor(private pool: Pool) {}
|
|
||||||
|
|
||||||
create = async (incident: Partial<Incident>): Promise<Incident> => {
|
|
||||||
const result = await this.pool.query<IncidentRow>(
|
|
||||||
`INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
||||||
RETURNING ${COLUMNS}`,
|
|
||||||
[
|
|
||||||
incident.monitorId,
|
|
||||||
incident.teamId,
|
|
||||||
incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(),
|
|
||||||
incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null,
|
|
||||||
incident.status ?? true,
|
|
||||||
incident.message ?? null,
|
|
||||||
incident.statusCode ?? null,
|
|
||||||
incident.resolutionType ?? null,
|
|
||||||
incident.resolvedBy ?? null,
|
|
||||||
incident.resolvedByEmail ?? null,
|
|
||||||
incident.comment ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Failed to create incident", status: 500 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
findById = async (incidentId: string, teamId: string): Promise<Incident> => {
|
|
||||||
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
findActiveByIncidentId = async (incidentId: string, teamId: string): Promise<Incident | null> => {
|
|
||||||
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [
|
|
||||||
incidentId,
|
|
||||||
teamId,
|
|
||||||
]);
|
|
||||||
const row = result.rows[0];
|
|
||||||
return row ? this.toEntity(row) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
findActiveByMonitorId = async (monitorId: string, teamId: string): Promise<Incident | null> => {
|
|
||||||
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [
|
|
||||||
monitorId,
|
|
||||||
teamId,
|
|
||||||
]);
|
|
||||||
const row = result.rows[0];
|
|
||||||
return row ? this.toEntity(row) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
findByTeamId = async (
|
|
||||||
teamId: string,
|
|
||||||
startDate: Date | undefined,
|
|
||||||
page: number,
|
|
||||||
rowsPerPage: number,
|
|
||||||
sortOrder?: string,
|
|
||||||
status?: boolean,
|
|
||||||
monitorId?: string,
|
|
||||||
resolutionType?: string
|
|
||||||
): Promise<Incident[]> => {
|
|
||||||
const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
|
|
||||||
const direction = sortOrder === "asc" ? "ASC" : "DESC";
|
|
||||||
const offset = page * rowsPerPage;
|
|
||||||
|
|
||||||
const result = await this.pool.query<IncidentRow>(
|
|
||||||
`SELECT ${COLUMNS} FROM incidents
|
|
||||||
WHERE ${conditions.join(" AND ")}
|
|
||||||
ORDER BY created_at ${direction}
|
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
||||||
[...values, rowsPerPage, offset]
|
|
||||||
);
|
|
||||||
return result.rows.map(this.toEntity);
|
|
||||||
};
|
|
||||||
|
|
||||||
countByTeamId = async (
|
|
||||||
teamId: string,
|
|
||||||
startDate: Date | undefined,
|
|
||||||
status?: boolean,
|
|
||||||
monitorId?: string,
|
|
||||||
resolutionType?: string
|
|
||||||
): Promise<number> => {
|
|
||||||
const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
|
|
||||||
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values);
|
|
||||||
return result.rows[0].count;
|
|
||||||
};
|
|
||||||
|
|
||||||
findSummaryByTeamId = async (teamId: string, limit?: number): Promise<IncidentSummary> => {
|
|
||||||
// Counts by status and resolution type
|
|
||||||
const countsResult = await this.pool.query(
|
|
||||||
`SELECT
|
|
||||||
COUNT(*)::int AS total,
|
|
||||||
COUNT(*) FILTER (WHERE status = TRUE)::int AS active,
|
|
||||||
COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual,
|
|
||||||
COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic
|
|
||||||
FROM incidents WHERE team_id = $1`,
|
|
||||||
[teamId]
|
|
||||||
);
|
|
||||||
const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 };
|
|
||||||
|
|
||||||
// Average resolution time
|
|
||||||
const resTimeResult = await this.pool.query(
|
|
||||||
`SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds
|
|
||||||
FROM incidents
|
|
||||||
WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`,
|
|
||||||
[teamId]
|
|
||||||
);
|
|
||||||
const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0;
|
|
||||||
const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100;
|
|
||||||
|
|
||||||
// Top monitor by incident count
|
|
||||||
const topMonitorResult = await this.pool.query(
|
|
||||||
`SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count
|
|
||||||
FROM incidents i
|
|
||||||
LEFT JOIN monitors m ON m.id = i.monitor_id
|
|
||||||
WHERE i.team_id = $1
|
|
||||||
GROUP BY i.monitor_id, m.name
|
|
||||||
ORDER BY count DESC
|
|
||||||
LIMIT 1`,
|
|
||||||
[teamId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Latest incidents
|
|
||||||
const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10);
|
|
||||||
const latestResult = await this.pool.query(
|
|
||||||
`SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time,
|
|
||||||
i.resolution_type, i.message, i.status_code, i.created_at
|
|
||||||
FROM incidents i
|
|
||||||
LEFT JOIN monitors m ON m.id = i.monitor_id
|
|
||||||
WHERE i.team_id = $1
|
|
||||||
ORDER BY i.created_at DESC
|
|
||||||
LIMIT $2`,
|
|
||||||
[teamId, latestLimit]
|
|
||||||
);
|
|
||||||
|
|
||||||
const topRow = topMonitorResult.rows[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: counts.total,
|
|
||||||
totalActive: counts.active,
|
|
||||||
totalManualResolutions: counts.manual,
|
|
||||||
totalAutomaticResolutions: counts.automatic,
|
|
||||||
avgResolutionTimeHours,
|
|
||||||
topMonitor: topRow
|
|
||||||
? {
|
|
||||||
monitorId: topRow.monitor_id,
|
|
||||||
monitorName: topRow.monitor_name ?? null,
|
|
||||||
incidentCount: topRow.count,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
latestIncidents: latestResult.rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
monitorId: row.monitor_id,
|
|
||||||
monitorName: row.monitor_name ?? null,
|
|
||||||
status: row.status,
|
|
||||||
startTime: row.start_time.toISOString(),
|
|
||||||
endTime: row.end_time ? row.end_time.toISOString() : null,
|
|
||||||
resolutionType: row.resolution_type ?? null,
|
|
||||||
message: row.message ?? null,
|
|
||||||
statusCode: row.status_code ?? null,
|
|
||||||
createdAt: row.created_at.toISOString(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>): Promise<Incident> => {
|
|
||||||
const sets: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
const fieldMap: [keyof Incident, string][] = [
|
|
||||||
["status", "status"],
|
|
||||||
["message", "message"],
|
|
||||||
["statusCode", "status_code"],
|
|
||||||
["endTime", "end_time"],
|
|
||||||
["resolutionType", "resolution_type"],
|
|
||||||
["resolvedBy", "resolved_by"],
|
|
||||||
["resolvedByEmail", "resolved_by_email"],
|
|
||||||
["comment", "comment"],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [key, column] of fieldMap) {
|
|
||||||
if (patch[key] !== undefined) {
|
|
||||||
const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key];
|
|
||||||
sets.push(`${column} = $${paramIndex++}`);
|
|
||||||
values.push(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sets.length === 0) {
|
|
||||||
return this.findById(incidentId, teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
sets.push(`updated_at = NOW()`);
|
|
||||||
values.push(incidentId, teamId);
|
|
||||||
|
|
||||||
const result = await this.pool.query<IncidentRow>(
|
|
||||||
`UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
|
|
||||||
RETURNING ${COLUMNS}`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteByMonitorId = async (monitorId: string, teamId: string): Promise<number> => {
|
|
||||||
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
|
|
||||||
if (!monitorIds.length) {
|
|
||||||
const result = await this.pool.query(`DELETE FROM incidents`);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
}
|
|
||||||
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]);
|
|
||||||
return result.rowCount ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => {
|
|
||||||
const conditions: string[] = ["team_id = $1"];
|
|
||||||
const values: unknown[] = [teamId];
|
|
||||||
let paramIndex = 2;
|
|
||||||
|
|
||||||
if (startDate) {
|
|
||||||
conditions.push(`created_at >= $${paramIndex++}`);
|
|
||||||
values.push(startDate);
|
|
||||||
}
|
|
||||||
if (status !== undefined) {
|
|
||||||
conditions.push(`status = $${paramIndex++}`);
|
|
||||||
values.push(status);
|
|
||||||
}
|
|
||||||
if (monitorId) {
|
|
||||||
conditions.push(`monitor_id = $${paramIndex++}`);
|
|
||||||
values.push(monitorId);
|
|
||||||
}
|
|
||||||
if (resolutionType) {
|
|
||||||
conditions.push(`resolution_type = $${paramIndex++}`);
|
|
||||||
values.push(resolutionType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { conditions, values, paramIndex };
|
|
||||||
};
|
|
||||||
|
|
||||||
private toEntity = (row: IncidentRow): Incident => ({
|
|
||||||
id: row.id,
|
|
||||||
monitorId: row.monitor_id,
|
|
||||||
teamId: row.team_id,
|
|
||||||
startTime: row.start_time.toISOString(),
|
|
||||||
endTime: row.end_time ? row.end_time.toISOString() : null,
|
|
||||||
status: row.status,
|
|
||||||
message: row.message ?? null,
|
|
||||||
statusCode: row.status_code ?? null,
|
|
||||||
resolutionType: row.resolution_type ?? null,
|
|
||||||
resolvedBy: row.resolved_by ?? null,
|
|
||||||
resolvedByEmail: row.resolved_by_email ?? null,
|
|
||||||
comment: row.comment ?? null,
|
|
||||||
createdAt: row.created_at.toISOString(),
|
|
||||||
updatedAt: row.updated_at.toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,38 @@
|
|||||||
export * from "@/repositories/monitors/IMonitorsRepository.js";
|
export * from "@/repositories/monitors/IMonitorsRepository.js";
|
||||||
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
|
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
|
||||||
export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js";
|
|
||||||
|
|
||||||
export * from "@/repositories/checks/IChecksRepository.js";
|
export * from "@/repositories/checks/IChecksRepository.js";
|
||||||
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.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 * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
|
||||||
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.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 * from "@/repositories/status-pages/IStatusPagesRepository.js";
|
||||||
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.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 * from "@/repositories/users/IUsersRepository.js";
|
||||||
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
|
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
|
||||||
export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js";
|
|
||||||
|
|
||||||
export * from "@/repositories/invites/IInvitesRepository.js";
|
export * from "@/repositories/invites/IInvitesRepository.js";
|
||||||
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.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 * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js";
|
||||||
export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.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 * from "@/repositories/settings/ISettingsRepository.js";
|
||||||
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
|
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
|
||||||
export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js";
|
|
||||||
|
|
||||||
export * from "@/repositories/notifications/INotificationsRepository.js";
|
export * from "@/repositories/notifications/INotificationsRepository.js";
|
||||||
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
|
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
|
||||||
export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js";
|
|
||||||
|
|
||||||
export * from "@/repositories/incidents/IIncidentsRepository.js";
|
export * from "@/repositories/incidents/IIncidentsRepository.js";
|
||||||
export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js";
|
export { default as MongoIncidentRepository } from "@/repositories/incidents/MongoIncidentRepository.js";
|
||||||
export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js";
|
|
||||||
|
|
||||||
export * from "@/repositories/teams/ITeamsRepository.js";
|
export * from "@/repositories/teams/ITeamsRepository.js";
|
||||||
export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.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 * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
|
||||||
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.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 * from "@/repositories/geo-checks/IGeoChecksRepository.js";
|
||||||
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
|
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
|
||||||
|
|
||||||
export * from "@/repositories/dlq/IDLQRepository.js";
|
|
||||||
export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js";
|
|
||||||
export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js";
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js";
|
|
||||||
import type { Invite } from "@/types/invite.js";
|
|
||||||
import type { UserRole } from "@/types/user.js";
|
|
||||||
import { AppError } from "@/utils/AppError.js";
|
|
||||||
|
|
||||||
const parsePostgresArray = (value: unknown): UserRole[] => {
|
|
||||||
if (typeof value !== "string") return [];
|
|
||||||
const inner = value.replace(/^\{|\}$/g, "");
|
|
||||||
if (inner === "") return [];
|
|
||||||
return inner.split(",") as UserRole[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InviteRow {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
team_id: string;
|
|
||||||
roles: UserRole[];
|
|
||||||
token: string;
|
|
||||||
expiry: Date | null;
|
|
||||||
created_at: Date;
|
|
||||||
updated_at: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`;
|
|
||||||
|
|
||||||
export class TimescaleInvitesRepository implements IInvitesRepository {
|
|
||||||
constructor(private pool: Pool) {}
|
|
||||||
|
|
||||||
create = async (invite: Partial<Invite>): Promise<Invite> => {
|
|
||||||
// Delete existing invites for this email
|
|
||||||
await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]);
|
|
||||||
|
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
|
||||||
const result = await this.pool.query<InviteRow>(
|
|
||||||
`INSERT INTO invites (email, team_id, roles, token, expiry)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING ${COLUMNS}`,
|
|
||||||
[invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null]
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Failed to create invite", status: 500 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
findByToken = async (token: string): Promise<Invite> => {
|
|
||||||
const result = await this.pool.query<InviteRow>(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Invite not found", status: 404 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
findByTokenAndDelete = async (token: string): Promise<Invite> => {
|
|
||||||
const result = await this.pool.query<InviteRow>(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Invite not found", status: 404 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
private toEntity = (row: InviteRow): Invite => ({
|
|
||||||
id: row.id,
|
|
||||||
email: row.email,
|
|
||||||
teamId: row.team_id,
|
|
||||||
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
|
|
||||||
token: row.token,
|
|
||||||
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
|
|
||||||
createdAt: row.created_at.toISOString(),
|
|
||||||
updatedAt: row.updated_at.toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import type { Pool } from "pg";
|
|
||||||
import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js";
|
|
||||||
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
|
|
||||||
import { AppError } from "@/utils/AppError.js";
|
|
||||||
|
|
||||||
interface MaintenanceWindowRow {
|
|
||||||
id: string;
|
|
||||||
monitor_id: string;
|
|
||||||
team_id: string;
|
|
||||||
active: boolean;
|
|
||||||
name: string;
|
|
||||||
duration: number;
|
|
||||||
duration_unit: DurationUnit;
|
|
||||||
repeat: number;
|
|
||||||
start_time: Date;
|
|
||||||
end_time: Date;
|
|
||||||
expiry: Date | null;
|
|
||||||
created_at: Date;
|
|
||||||
updated_at: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat,
|
|
||||||
start_time, end_time, expiry, created_at, updated_at`;
|
|
||||||
|
|
||||||
export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository {
|
|
||||||
constructor(private pool: Pool) {}
|
|
||||||
|
|
||||||
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
|
|
||||||
const startTime = data.start ? new Date(data.start) : null;
|
|
||||||
const endTime = data.end ? new Date(data.end) : null;
|
|
||||||
// One-time windows expire at end time
|
|
||||||
const expiry = data.repeat === 0 ? endTime : null;
|
|
||||||
|
|
||||||
const result = await this.pool.query<MaintenanceWindowRow>(
|
|
||||||
`INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
||||||
RETURNING ${COLUMNS}`,
|
|
||||||
[
|
|
||||||
data.monitorId,
|
|
||||||
data.teamId,
|
|
||||||
data.active ?? true,
|
|
||||||
data.name ?? null,
|
|
||||||
data.duration ?? null,
|
|
||||||
data.durationUnit ?? null,
|
|
||||||
data.repeat ?? 0,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
expiry,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Failed to create maintenance window", status: 500 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
findById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
|
|
||||||
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [
|
|
||||||
id,
|
|
||||||
teamId,
|
|
||||||
]);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Maintenance Window not found", status: 404 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
|
|
||||||
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [
|
|
||||||
monitorId,
|
|
||||||
teamId,
|
|
||||||
]);
|
|
||||||
return result.rows.map(this.toEntity);
|
|
||||||
};
|
|
||||||
|
|
||||||
findByTeamId = async (
|
|
||||||
teamId: string,
|
|
||||||
page: number,
|
|
||||||
rowsPerPage: number,
|
|
||||||
field?: string,
|
|
||||||
order?: string,
|
|
||||||
active?: boolean
|
|
||||||
): Promise<MaintenanceWindow[]> => {
|
|
||||||
const conditions: string[] = ["team_id = $1"];
|
|
||||||
const values: unknown[] = [teamId];
|
|
||||||
let paramIndex = 2;
|
|
||||||
|
|
||||||
if (active !== undefined) {
|
|
||||||
conditions.push(`active = $${paramIndex++}`);
|
|
||||||
values.push(active);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldMap: Record<string, string> = {
|
|
||||||
createdAt: "created_at",
|
|
||||||
name: "name",
|
|
||||||
start: "start_time",
|
|
||||||
end: "end_time",
|
|
||||||
active: "active",
|
|
||||||
};
|
|
||||||
const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at";
|
|
||||||
const sortDirection = order === "asc" ? "ASC" : "DESC";
|
|
||||||
|
|
||||||
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
|
|
||||||
|
|
||||||
const result = await this.pool.query<MaintenanceWindowRow>(
|
|
||||||
`SELECT ${COLUMNS} FROM maintenance_windows
|
|
||||||
WHERE ${conditions.join(" AND ")}
|
|
||||||
ORDER BY ${sortColumn} ${sortDirection}
|
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
|
||||||
[...values, rowsPerPage, offset]
|
|
||||||
);
|
|
||||||
return result.rows.map(this.toEntity);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateById = async (id: string, teamId: string, patch: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
|
|
||||||
const sets: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [
|
|
||||||
["active", "active", false],
|
|
||||||
["name", "name", false],
|
|
||||||
["duration", "duration", false],
|
|
||||||
["durationUnit", "duration_unit", false],
|
|
||||||
["repeat", "repeat", false],
|
|
||||||
["start", "start_time", true],
|
|
||||||
["end", "end_time", true],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [key, column, isDate] of fieldMap) {
|
|
||||||
if (patch[key] !== undefined) {
|
|
||||||
sets.push(`${column} = $${paramIndex++}`);
|
|
||||||
values.push(isDate ? new Date(patch[key] as string) : patch[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sets.length === 0) {
|
|
||||||
return this.findById(id, teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
sets.push(`updated_at = NOW()`);
|
|
||||||
values.push(id, teamId);
|
|
||||||
|
|
||||||
const result = await this.pool.query<MaintenanceWindowRow>(
|
|
||||||
`UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
|
|
||||||
RETURNING ${COLUMNS}`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
deleteById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
|
|
||||||
const result = await this.pool.query<MaintenanceWindowRow>(
|
|
||||||
`DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`,
|
|
||||||
[id, teamId]
|
|
||||||
);
|
|
||||||
const row = result.rows[0];
|
|
||||||
if (!row) {
|
|
||||||
throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 });
|
|
||||||
}
|
|
||||||
return this.toEntity(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
countByTeamId = async (teamId: string, active?: boolean): Promise<number> => {
|
|
||||||
const conditions: string[] = ["team_id = $1"];
|
|
||||||
const values: unknown[] = [teamId];
|
|
||||||
|
|
||||||
if (active !== undefined) {
|
|
||||||
conditions.push(`active = $2`);
|
|
||||||
values.push(active);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values);
|
|
||||||
return result.rows[0].count;
|
|
||||||
};
|
|
||||||
|
|
||||||
private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({
|
|
||||||
id: row.id,
|
|
||||||
monitorId: row.monitor_id,
|
|
||||||
teamId: row.team_id,
|
|
||||||
active: row.active,
|
|
||||||
name: row.name,
|
|
||||||
duration: row.duration,
|
|
||||||
durationUnit: row.duration_unit,
|
|
||||||
repeat: row.repeat,
|
|
||||||
start: row.start_time.toISOString(),
|
|
||||||
end: row.end_time.toISOString(),
|
|
||||||
createdAt: row.created_at.toISOString(),
|
|
||||||
updatedAt: row.updated_at.toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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
@@ -1,140 +0,0 @@
|
|||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
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,
|
numericUnit: audit.numericUnit,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
check.accessibility = (categories.accessibility?.score || 0) * 100;
|
check.accessibility = (categories?.accessibility?.score || 0) * 100;
|
||||||
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
|
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
|
||||||
check.seo = (categories.seo?.score || 0) * 100;
|
check.seo = (categories?.seo?.score || 0) * 100;
|
||||||
check.performance = (categories.performance?.score || 0) * 100;
|
check.performance = (categories?.performance?.score || 0) * 100;
|
||||||
check.audits = {
|
check.audits = {
|
||||||
cls: mapAudit(audits["cumulative-layout-shift"]),
|
cls: mapAudit(audits?.["cumulative-layout-shift"]),
|
||||||
si: mapAudit(audits["speed-index"]),
|
si: mapAudit(audits?.["speed-index"]),
|
||||||
fcp: mapAudit(audits["first-contentful-paint"]),
|
fcp: mapAudit(audits?.["first-contentful-paint"]),
|
||||||
lcp: mapAudit(audits["largest-contentful-paint"]),
|
lcp: mapAudit(audits?.["largest-contentful-paint"]),
|
||||||
tbt: mapAudit(audits["total-blocking-time"]),
|
tbt: mapAudit(audits?.["total-blocking-time"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
|
|||||||
const hardwarePayload = payload as HardwareStatusPayload | undefined;
|
const hardwarePayload = payload as HardwareStatusPayload | undefined;
|
||||||
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
|
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
|
||||||
const errorsSource = Array.isArray(hardwarePayload?.errors)
|
const errorsSource = Array.isArray(hardwarePayload?.errors)
|
||||||
? hardwarePayload.errors
|
? hardwarePayload?.errors
|
||||||
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
|
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
|
||||||
check.cpu = cpu;
|
check.cpu = cpu;
|
||||||
check.memory = memory;
|
check.memory = memory;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface IGeoChecksService {
|
|||||||
dateRange: string;
|
dateRange: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
rowsPerPage?: number;
|
rowsPerPage?: number;
|
||||||
continent?: GeoContinent | GeoContinent[];
|
continent: GeoContinent | GeoContinent[];
|
||||||
}): Promise<FlatGeoChecksQueryResult>;
|
}): Promise<FlatGeoChecksQueryResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,14 +95,17 @@ export class IncidentService implements IIncidentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decision.shouldResolveIncident || !activeIncident) {
|
if (decision.shouldResolveIncident) {
|
||||||
return null;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
activeIncident.status = false;
|
return null;
|
||||||
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 {
|
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
|
|||||||
export * from "@/service/infrastructure/notificationProviders/slack.js";
|
export * from "@/service/infrastructure/notificationProviders/slack.js";
|
||||||
export * from "@/service/infrastructure/notificationProviders/teams.js";
|
export * from "@/service/infrastructure/notificationProviders/teams.js";
|
||||||
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||||
export * from "@/service/infrastructure/notificationProviders/telegram.js";
|
|
||||||
|
|
||||||
// System services
|
// System services
|
||||||
export * from "@/service/system/settingsService.js";
|
export * from "@/service/system/settingsService.js";
|
||||||
@@ -73,91 +73,22 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
return SuperSimpleQueue.SERVICE_NAME;
|
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) {
|
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
|
||||||
const scheduler = new Scheduler({
|
const scheduler = new Scheduler({
|
||||||
// storeType: "mongo",
|
// storeType: "mongo",
|
||||||
// storeType: "redis",
|
// storeType: "redis",
|
||||||
|
logLevel: "debug",
|
||||||
// dbUri: envSettings.dbConnectionString,
|
// dbUri: envSettings.dbConnectionString,
|
||||||
});
|
});
|
||||||
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
|
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
|
||||||
await instance.init();
|
await instance.init();
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
init = async () => {
|
init = async () => {
|
||||||
try {
|
try {
|
||||||
this.registerListeners();
|
|
||||||
|
|
||||||
this.scheduler.start();
|
this.scheduler.start();
|
||||||
|
|
||||||
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
|
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
|
||||||
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
|
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
|
||||||
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
|
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
|
||||||
@@ -215,8 +146,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
|
|
||||||
deleteJob = async (monitor: Monitor) => {
|
deleteJob = async (monitor: Monitor) => {
|
||||||
this.scheduler.removeJob(monitor.id);
|
this.scheduler.removeJob(monitor.id);
|
||||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
this.scheduler.removeJob(`${monitor.id}-geo`);
|
||||||
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pauseJob = async (monitor: Monitor) => {
|
pauseJob = async (monitor: Monitor) => {
|
||||||
@@ -224,9 +154,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
if (result === false) {
|
if (result === false) {
|
||||||
throw new Error("Failed to pause monitor");
|
throw new Error("Failed to pause monitor");
|
||||||
}
|
}
|
||||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
await this.scheduler.pauseJob(`${monitor.id}-geo`);
|
||||||
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
|
|
||||||
|
|
||||||
this.logger.debug({
|
this.logger.debug({
|
||||||
message: `Paused monitor ${monitor.id}`,
|
message: `Paused monitor ${monitor.id}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
@@ -249,35 +177,31 @@ 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) => {
|
updateJob = async (monitor: Monitor) => {
|
||||||
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor });
|
||||||
await this.syncGeoJob(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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
shutdown = async () => {
|
shutdown = async () => {
|
||||||
@@ -306,8 +230,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
if (failCount > 0) {
|
if (failCount > 0) {
|
||||||
acc.jobsWithFailures.push({
|
acc.jobsWithFailures.push({
|
||||||
monitorId: job.id,
|
monitorId: job.id,
|
||||||
monitorUrl: job.data?.url || null,
|
monitorUrl: job?.data?.url || null,
|
||||||
monitorType: job.data?.type || null,
|
monitorType: job?.data?.type || null,
|
||||||
failedAt: job.lastFailedAt ?? null,
|
failedAt: job.lastFailedAt ?? null,
|
||||||
failCount,
|
failCount,
|
||||||
failReason: job.lastFailReason ?? null,
|
failReason: job.lastFailReason ?? null,
|
||||||
@@ -339,9 +263,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
return jobs.map((job) => {
|
return jobs.map((job) => {
|
||||||
return {
|
return {
|
||||||
monitorId: job.id,
|
monitorId: job.id,
|
||||||
monitorUrl: job.data?.url || null,
|
monitorUrl: job?.data?.url || null,
|
||||||
monitorType: job.data?.type || null,
|
monitorType: job?.data?.type || null,
|
||||||
monitorInterval: job.data?.interval || null,
|
monitorInterval: job?.data?.interval || null,
|
||||||
active: job.active,
|
active: job.active,
|
||||||
lockedAt: job.lockedAt ?? null,
|
lockedAt: job.lockedAt ?? null,
|
||||||
runCount: job.runCount ?? 0,
|
runCount: job.runCount ?? 0,
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
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,7 +33,6 @@ export class NotificationsService implements INotificationsService {
|
|||||||
private pagerDutyProvider: INotificationProvider;
|
private pagerDutyProvider: INotificationProvider;
|
||||||
private matrixProvider: INotificationProvider;
|
private matrixProvider: INotificationProvider;
|
||||||
private teamsProvider: INotificationProvider;
|
private teamsProvider: INotificationProvider;
|
||||||
private telegramProvider: INotificationProvider;
|
|
||||||
private logger: ILogger;
|
private logger: ILogger;
|
||||||
private settingsService: ISettingsService;
|
private settingsService: ISettingsService;
|
||||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||||
@@ -48,7 +47,6 @@ export class NotificationsService implements INotificationsService {
|
|||||||
pagerDutyProvider: INotificationProvider,
|
pagerDutyProvider: INotificationProvider,
|
||||||
matrixProvider: INotificationProvider,
|
matrixProvider: INotificationProvider,
|
||||||
teamsProvider: INotificationProvider,
|
teamsProvider: INotificationProvider,
|
||||||
telegramProvider: INotificationProvider,
|
|
||||||
settingsService: ISettingsService,
|
settingsService: ISettingsService,
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
notificationMessageBuilder: INotificationMessageBuilder
|
notificationMessageBuilder: INotificationMessageBuilder
|
||||||
@@ -62,7 +60,6 @@ export class NotificationsService implements INotificationsService {
|
|||||||
this.pagerDutyProvider = pagerDutyProvider;
|
this.pagerDutyProvider = pagerDutyProvider;
|
||||||
this.matrixProvider = matrixProvider;
|
this.matrixProvider = matrixProvider;
|
||||||
this.teamsProvider = teamsProvider;
|
this.teamsProvider = teamsProvider;
|
||||||
this.telegramProvider = telegramProvider;
|
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||||
@@ -100,8 +97,6 @@ export class NotificationsService implements INotificationsService {
|
|||||||
return await this.emailProvider.sendMessage!(notification, notificationMessage);
|
return await this.emailProvider.sendMessage!(notification, notificationMessage);
|
||||||
case "teams":
|
case "teams":
|
||||||
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
|
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
|
||||||
case "telegram":
|
|
||||||
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
|
|
||||||
default:
|
default:
|
||||||
this.logger.warn({
|
this.logger.warn({
|
||||||
message: `Unknown notification type: ${notification.type}`,
|
message: `Unknown notification type: ${notification.type}`,
|
||||||
@@ -162,8 +157,6 @@ export class NotificationsService implements INotificationsService {
|
|||||||
return await this.webhookProvider.sendTestAlert(notification);
|
return await this.webhookProvider.sendTestAlert(notification);
|
||||||
case "teams":
|
case "teams":
|
||||||
return await this.teamsProvider.sendTestAlert(notification);
|
return await this.teamsProvider.sendTestAlert(notification);
|
||||||
case "telegram":
|
|
||||||
return await this.telegramProvider.sendTestAlert(notification);
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,13 @@ export class StatusService implements IStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate uptime percentage
|
// Calculate uptime percentage
|
||||||
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
|
let uptimePercentage;
|
||||||
|
if (stats.totalChecks > 0) {
|
||||||
|
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
|
||||||
|
} else {
|
||||||
|
uptimePercentage = status === true ? 100 : 0;
|
||||||
|
}
|
||||||
|
stats.uptimePercentage = uptimePercentage;
|
||||||
|
|
||||||
// latest check
|
// latest check
|
||||||
stats.lastCheckTimestamp = new Date().getTime();
|
stats.lastCheckTimestamp = new Date().getTime();
|
||||||
@@ -232,7 +238,7 @@ export class StatusService implements IStatusService {
|
|||||||
|
|
||||||
// Return early if not enough data points
|
// Return early if not enough data points
|
||||||
if (monitor.statusWindow.length < monitor.statusWindowSize) {
|
if (monitor.statusWindow.length < monitor.statusWindowSize) {
|
||||||
monitor.status = newStatus;
|
monitor.status = "initializing";
|
||||||
const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);
|
const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);
|
||||||
return {
|
return {
|
||||||
monitor: updated,
|
monitor: updated,
|
||||||
@@ -262,7 +268,7 @@ export class StatusService implements IStatusService {
|
|||||||
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
|
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
|
||||||
if (monitor.type === "hardware" && statusResponse.payload) {
|
if (monitor.type === "hardware" && statusResponse.payload) {
|
||||||
const payload = statusResponse.payload as HardwareStatusPayload;
|
const payload = statusResponse.payload as HardwareStatusPayload;
|
||||||
const metrics = payload.data;
|
const metrics = payload?.data;
|
||||||
|
|
||||||
if (metrics) {
|
if (metrics) {
|
||||||
// Evaluate threshold breaches
|
// Evaluate threshold breaches
|
||||||
@@ -272,11 +278,9 @@ export class StatusService implements IStatusService {
|
|||||||
const memoryUsage = metrics.memory?.usage_percent ?? -1;
|
const memoryUsage = metrics.memory?.usage_percent ?? -1;
|
||||||
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
|
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
|
||||||
|
|
||||||
const diskBreach = metrics.disk
|
const diskBreach =
|
||||||
? metrics.disk.some(
|
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
|
||||||
(d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100
|
false;
|
||||||
)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const temps = metrics.cpu?.temperature ?? [];
|
const temps = metrics.cpu?.temperature ?? [];
|
||||||
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
|
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ISettingsRepository } from "@/repositories/index.js";
|
import { ISettingsRepository } from "@/repositories/index.js";
|
||||||
import { type DbType, Settings, SettingsUpdate } from "@/types/index.js";
|
import { Settings, SettingsUpdate } from "@/types/index.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { ValidatedEnv } from "@/validation/envValidation.js";
|
import { ValidatedEnv } from "@/validation/envValidation.js";
|
||||||
import type { StringValue } from "ms";
|
import type { StringValue } from "ms";
|
||||||
@@ -12,7 +12,6 @@ export type EnvConfig = {
|
|||||||
logLevel: string;
|
logLevel: string;
|
||||||
clientHost: string;
|
clientHost: string;
|
||||||
dbConnectionString: string;
|
dbConnectionString: string;
|
||||||
dbType: DbType;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ISettingsService {
|
export interface ISettingsService {
|
||||||
@@ -26,9 +25,10 @@ export interface ISettingsService {
|
|||||||
export class SettingsService implements ISettingsService {
|
export class SettingsService implements ISettingsService {
|
||||||
static SERVICE_NAME = SERVICE_NAME;
|
static SERVICE_NAME = SERVICE_NAME;
|
||||||
private settings: EnvConfig;
|
private settings: EnvConfig;
|
||||||
private settingsRepository: ISettingsRepository | null = null;
|
private settingsRepository: ISettingsRepository;
|
||||||
|
|
||||||
constructor(env: ValidatedEnv) {
|
constructor(settingsRepository: ISettingsRepository, env: ValidatedEnv) {
|
||||||
|
this.settingsRepository = settingsRepository;
|
||||||
this.settings = {
|
this.settings = {
|
||||||
jwtSecret: env.JWT_SECRET,
|
jwtSecret: env.JWT_SECRET,
|
||||||
jwtTTL: env.TOKEN_TTL as StringValue,
|
jwtTTL: env.TOKEN_TTL as StringValue,
|
||||||
@@ -36,14 +36,9 @@ export class SettingsService implements ISettingsService {
|
|||||||
logLevel: env.LOG_LEVEL,
|
logLevel: env.LOG_LEVEL,
|
||||||
clientHost: env.CLIENT_HOST,
|
clientHost: env.CLIENT_HOST,
|
||||||
dbConnectionString: env.DB_CONNECTION_STRING,
|
dbConnectionString: env.DB_CONNECTION_STRING,
|
||||||
dbType: env.DB_TYPE,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setRepository(settingsRepository: ISettingsRepository) {
|
|
||||||
this.settingsRepository = settingsRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
get serviceName() {
|
get serviceName() {
|
||||||
return SettingsService.SERVICE_NAME;
|
return SettingsService.SERVICE_NAME;
|
||||||
}
|
}
|
||||||
@@ -59,30 +54,18 @@ export class SettingsService implements ISettingsService {
|
|||||||
return this.settings;
|
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) => {
|
updateDbSettings = async (newSettings: SettingsUpdate) => {
|
||||||
return await this.getRepository().update(newSettings);
|
return await this.settingsRepository.update(newSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
getDBSettings = async () => {
|
getDBSettings = async () => {
|
||||||
const repo = this.getRepository();
|
|
||||||
// Remove any old settings
|
// Remove any old settings
|
||||||
await repo.deleteLegacy();
|
await this.settingsRepository.deleteLegacy();
|
||||||
|
|
||||||
let settings = await repo.findSingleton();
|
let settings = await this.settingsRepository.findSingleton();
|
||||||
if (settings === null) {
|
if (settings === null) {
|
||||||
await repo.create({});
|
await this.settingsRepository.create({});
|
||||||
settings = await repo.findSingleton();
|
settings = await this.settingsRepository.findSingleton();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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,4 +14,3 @@ export * from "@/types/incident.js";
|
|||||||
export * from "@/types/email.js";
|
export * from "@/types/email.js";
|
||||||
export * from "@/types/team.js";
|
export * from "@/types/team.js";
|
||||||
export * from "@/types/maintenanceWindow.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", "telegram"] as const;
|
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
|
||||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
export const DbTypes = ["timescaledb", "mongodb"] as const;
|
|
||||||
export type DbType = (typeof DbTypes)[number];
|
|
||||||
|
|
||||||
export interface SettingsThresholds {
|
export interface SettingsThresholds {
|
||||||
cpu?: number;
|
cpu?: number;
|
||||||
memory?: number;
|
memory?: number;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { DbTypes } from "@/types/settings.js";
|
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
// Server Configuration
|
// Server Configuration
|
||||||
@@ -9,7 +8,6 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
|
DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"),
|
||||||
DB_TYPE: z.enum(DbTypes).default("mongodb"),
|
|
||||||
|
|
||||||
// JWT Authentication
|
// JWT Authentication
|
||||||
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { booleanCoercion } from "./shared.js";
|
|||||||
|
|
||||||
export const getIncidentsByTeamQueryValidation = z.object({
|
export const getIncidentsByTeamQueryValidation = z.object({
|
||||||
sortOrder: z.enum(["asc", "desc"]),
|
sortOrder: z.enum(["asc", "desc"]),
|
||||||
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).default("all"),
|
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]),
|
||||||
page: z.coerce.number().int().min(0),
|
page: z.coerce.number().int().min(0),
|
||||||
rowsPerPage: z.coerce.number().int().min(1),
|
rowsPerPage: z.coerce.number().int().min(1),
|
||||||
status: booleanCoercion.optional(),
|
status: booleanCoercion.optional(),
|
||||||
|
|||||||
@@ -65,13 +65,6 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("teams"),
|
type: z.literal("teams"),
|
||||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
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;
|
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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() }),
|
|
||||||
});
|
|
||||||
+63
-58
@@ -1,13 +1,6 @@
|
|||||||
import { jest } from "@jest/globals";
|
import { jest } from "@jest/globals";
|
||||||
import { MonitorService } from "../../../src/service/business/monitorService.ts";
|
import { MonitorService } from "../src/service/business/monitorService.ts";
|
||||||
import type {
|
import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts";
|
||||||
IChecksRepository,
|
|
||||||
IGeoChecksRepository,
|
|
||||||
IIncidentsRepository,
|
|
||||||
IMonitorStatsRepository,
|
|
||||||
IMonitorsRepository,
|
|
||||||
IStatusPagesRepository,
|
|
||||||
} from "../../../src/repositories/index.ts";
|
|
||||||
|
|
||||||
const createMonitorsRepositoryMock = () =>
|
const createMonitorsRepositoryMock = () =>
|
||||||
({
|
({
|
||||||
@@ -15,11 +8,16 @@ const createMonitorsRepositoryMock = () =>
|
|||||||
findByTeamId: jest.fn(),
|
findByTeamId: jest.fn(),
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
findMonitorsSummaryByTeamId: jest.fn(),
|
findMonitorsSummaryByTeamId: jest.fn(),
|
||||||
|
findGroupsByTeamId: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
createBulkMonitors: jest.fn(),
|
||||||
|
deleteByTeamId: jest.fn(),
|
||||||
}) as unknown as IMonitorsRepository;
|
}) as unknown as IMonitorsRepository;
|
||||||
|
|
||||||
const createChecksRepositoryMock = () =>
|
const createChecksRepositoryMock = () =>
|
||||||
({
|
({
|
||||||
findByDateRangeAndMonitorId: jest.fn(),
|
findLatestChecksByMonitorIds: jest.fn(),
|
||||||
|
findDateRangeChecksByMonitor: jest.fn(),
|
||||||
}) as unknown as IChecksRepository;
|
}) as unknown as IChecksRepository;
|
||||||
|
|
||||||
const createMonitorStatsRepositoryMock = () =>
|
const createMonitorStatsRepositoryMock = () =>
|
||||||
@@ -33,84 +31,76 @@ const createStatusPagesRepositoryMock = () =>
|
|||||||
removeMonitorFromStatusPages: jest.fn(),
|
removeMonitorFromStatusPages: jest.fn(),
|
||||||
}) as unknown as IStatusPagesRepository;
|
}) as unknown as IStatusPagesRepository;
|
||||||
|
|
||||||
const createGeoChecksRepositoryMock = () => ({}) as unknown as IGeoChecksRepository;
|
|
||||||
|
|
||||||
const createIncidentsRepositoryMock = () => ({}) as unknown as IIncidentsRepository;
|
|
||||||
|
|
||||||
const createService = ({
|
const createService = ({
|
||||||
monitorsRepository = createMonitorsRepositoryMock(),
|
monitorsRepository = createMonitorsRepositoryMock(),
|
||||||
checksRepository = createChecksRepositoryMock(),
|
checksRepository = createChecksRepositoryMock(),
|
||||||
monitorStatsRepository = createMonitorStatsRepositoryMock(),
|
monitorStatsRepository = createMonitorStatsRepositoryMock(),
|
||||||
statusPagesRepository = createStatusPagesRepositoryMock(),
|
statusPagesRepository = createStatusPagesRepositoryMock(),
|
||||||
geoChecksRepository = createGeoChecksRepositoryMock(),
|
|
||||||
incidentsRepository = createIncidentsRepositoryMock(),
|
|
||||||
}: {
|
}: {
|
||||||
monitorsRepository?: IMonitorsRepository;
|
monitorsRepository?: IMonitorsRepository;
|
||||||
checksRepository?: IChecksRepository;
|
checksRepository?: IChecksRepository;
|
||||||
monitorStatsRepository?: IMonitorStatsRepository;
|
monitorStatsRepository?: IMonitorStatsRepository;
|
||||||
statusPagesRepository?: IStatusPagesRepository;
|
statusPagesRepository?: IStatusPagesRepository;
|
||||||
geoChecksRepository?: IGeoChecksRepository;
|
|
||||||
incidentsRepository?: IIncidentsRepository;
|
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
return new MonitorService({
|
return new MonitorService({
|
||||||
|
db: {
|
||||||
|
checkModule: { deleteChecks: jest.fn() },
|
||||||
|
statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() },
|
||||||
|
pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() },
|
||||||
|
notificationModule: { deleteNotificationsByMonitorId: jest.fn() },
|
||||||
|
},
|
||||||
jobQueue: {
|
jobQueue: {
|
||||||
addJob: jest.fn(),
|
addJob: jest.fn(),
|
||||||
updateJob: jest.fn(),
|
updateJob: jest.fn(),
|
||||||
resumeJob: jest.fn(),
|
resumeJob: jest.fn(),
|
||||||
pauseJob: jest.fn(),
|
pauseJob: jest.fn(),
|
||||||
deleteJob: jest.fn(),
|
deleteJob: jest.fn(),
|
||||||
} as any,
|
},
|
||||||
emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() } as any,
|
stringService: {},
|
||||||
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
|
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")),
|
||||||
|
},
|
||||||
games: [],
|
games: [],
|
||||||
monitorsRepository,
|
monitorsRepository,
|
||||||
checksRepository,
|
checksRepository,
|
||||||
geoChecksRepository,
|
|
||||||
monitorStatsRepository,
|
monitorStatsRepository,
|
||||||
statusPagesRepository,
|
statusPagesRepository,
|
||||||
incidentsRepository,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("MonitorService", () => {
|
describe("MonitorService", () => {
|
||||||
describe("getMonitorsWithChecksByTeamId", () => {
|
describe("getMonitorsWithChecksByTeamId", () => {
|
||||||
it("returns monitors enriched with normalized recentChecks", async () => {
|
it("returns monitors enriched with normalized checks", async () => {
|
||||||
const monitorsRepository = createMonitorsRepositoryMock();
|
const monitorsRepository = createMonitorsRepositoryMock();
|
||||||
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2);
|
(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([
|
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([
|
||||||
{
|
{ id: "m1", name: "Monitor 1", interval: 60000 },
|
||||||
id: "m1",
|
{ id: "m2", name: "Monitor 2", interval: 60000 },
|
||||||
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 service = createService({ monitorsRepository });
|
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 result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
||||||
|
|
||||||
expect(result).toMatchObject({ count: 2 });
|
expect(result).toMatchObject({ count: 2 });
|
||||||
expect(result.monitors).toHaveLength(2);
|
expect(result.monitors).toHaveLength(2);
|
||||||
expect(result.monitors[0]).toHaveProperty("recentChecks");
|
expect(result.monitors[0]).toHaveProperty("checks");
|
||||||
expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0);
|
expect(result.monitors[0].checks.length).toBeGreaterThan(0);
|
||||||
expect(result.monitors[0].recentChecks[0]).toEqual(
|
expect(result.monitors[0].checks[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
responseTime: expect.any(Number),
|
responseTime: expect.any(Number),
|
||||||
status: expect.any(Boolean),
|
status: expect.any(Boolean),
|
||||||
@@ -130,15 +120,15 @@ describe("MonitorService", () => {
|
|||||||
const service = createService({ monitorsRepository });
|
const service = createService({ monitorsRepository });
|
||||||
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
|
const result = await service.getMonitorsByTeamId({ teamId: "team" } as any);
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result![0]).toHaveProperty("id", "m1");
|
expect(result[0]).toHaveProperty("id", "m1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getMonitorsWithChecksByTeamId summary", () => {
|
describe("getMonitorsWithChecksByTeamId summary", () => {
|
||||||
it("includes summary and monitors with recentChecks", async () => {
|
it("includes summary and monitors with checks", async () => {
|
||||||
const monitorsRepository = createMonitorsRepositoryMock();
|
const monitorsRepository = createMonitorsRepositoryMock();
|
||||||
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
|
(monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1);
|
||||||
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http", recentChecks: [] }]);
|
(monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]);
|
||||||
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
|
(monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({
|
||||||
totalMonitors: 1,
|
totalMonitors: 1,
|
||||||
upMonitors: 1,
|
upMonitors: 1,
|
||||||
@@ -146,12 +136,15 @@ describe("MonitorService", () => {
|
|||||||
pausedMonitors: 0,
|
pausedMonitors: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = createService({ monitorsRepository });
|
const checksRepository = createChecksRepositoryMock();
|
||||||
|
(checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] });
|
||||||
|
|
||||||
|
const service = createService({ monitorsRepository, checksRepository });
|
||||||
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
|
summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 },
|
||||||
count: 1,
|
count: 1,
|
||||||
monitors: [{ id: "m1", type: "http", recentChecks: [] }],
|
monitors: [{ id: "m1", type: "http", checks: [] }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -162,11 +155,23 @@ describe("MonitorService", () => {
|
|||||||
const monitor = {
|
const monitor = {
|
||||||
id: "monitor-1",
|
id: "monitor-1",
|
||||||
teamId: TEAM_ID,
|
teamId: TEAM_ID,
|
||||||
name: "HTTP monitor",
|
name: "Hardware monitor",
|
||||||
interval: 60000,
|
interval: 60000,
|
||||||
|
statusWindow: [],
|
||||||
|
statusWindowSize: 5,
|
||||||
|
statusWindowThreshold: 60,
|
||||||
type: "http",
|
type: "http",
|
||||||
|
ignoreTlsErrors: false,
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
alertThreshold: 5,
|
||||||
|
cpuAlertThreshold: 5,
|
||||||
|
memoryAlertThreshold: 5,
|
||||||
|
diskAlertThreshold: 5,
|
||||||
|
tempAlertThreshold: 5,
|
||||||
|
selectedDisks: [],
|
||||||
|
notifications: [],
|
||||||
|
group: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -174,7 +179,7 @@ describe("MonitorService", () => {
|
|||||||
const monitorsRepository = createMonitorsRepositoryMock();
|
const monitorsRepository = createMonitorsRepositoryMock();
|
||||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||||
const checksRepository = createChecksRepositoryMock();
|
const checksRepository = createChecksRepositoryMock();
|
||||||
(checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({
|
(checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({
|
||||||
monitorType: "http",
|
monitorType: "http",
|
||||||
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
|
groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }],
|
||||||
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
|
groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }],
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
-30
@@ -1,15 +1,19 @@
|
|||||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||||
import type { Notification } from "../../../src/types/notification.ts";
|
import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts";
|
||||||
import type { NotificationMessage } from "../../../src/types/notificationMessage.ts";
|
import type { Notification } from "../src/types/notification.ts";
|
||||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
import type { NotificationMessage } from "../src/types/notificationMessage.ts";
|
||||||
|
|
||||||
const mockPost = jest.fn();
|
// Mock got
|
||||||
jest.unstable_mockModule("got", () => ({
|
jest.unstable_mockModule("got", () => ({
|
||||||
default: { post: mockPost },
|
default: { post: jest.fn() },
|
||||||
HTTPError: class HTTPError extends Error {},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts");
|
const createLogger = () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
||||||
id: "notif-1",
|
id: "notif-1",
|
||||||
@@ -54,14 +58,17 @@ const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMe
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TeamsProvider", () => {
|
describe("TeamsProvider", () => {
|
||||||
let provider: InstanceType<typeof TeamsProvider>;
|
let provider: TeamsProvider;
|
||||||
let logger: ReturnType<typeof createMockLogger>;
|
let logger: ReturnType<typeof createLogger>;
|
||||||
|
let gotPost: jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
logger = createMockLogger();
|
logger = createLogger();
|
||||||
provider = new TeamsProvider(logger);
|
provider = new TeamsProvider(logger);
|
||||||
mockPost.mockReset();
|
const got = await import("got");
|
||||||
mockPost.mockResolvedValue({});
|
gotPost = got.default.post as jest.Mock;
|
||||||
|
gotPost.mockReset();
|
||||||
|
gotPost.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendTestAlert", () => {
|
describe("sendTestAlert", () => {
|
||||||
@@ -69,33 +76,35 @@ describe("TeamsProvider", () => {
|
|||||||
const notification = createNotification({ address: undefined });
|
const notification = createNotification({ address: undefined });
|
||||||
const result = await provider.sendTestAlert(notification);
|
const result = await provider.sendTestAlert(notification);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
expect(gotPost).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends an Adaptive Card test message and returns true on success", async () => {
|
it("sends an Adaptive Card test message and returns true on success", async () => {
|
||||||
const notification = createNotification();
|
const notification = createNotification();
|
||||||
const result = await provider.sendTestAlert(notification);
|
const result = await provider.sendTestAlert(notification);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const [url, options] = mockPost.mock.calls[0] as [string, { json: any; headers: any }];
|
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||||
expect(url).toBe(notification.address);
|
expect(url).toBe(notification.address);
|
||||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||||
|
|
||||||
|
// Verify Teams webhook envelope
|
||||||
const payload = options.json;
|
const payload = options.json;
|
||||||
expect(payload.type).toBe("message");
|
expect(payload.type).toBe("message");
|
||||||
expect(payload.attachments).toHaveLength(1);
|
expect(payload.attachments).toHaveLength(1);
|
||||||
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
|
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
|
||||||
|
|
||||||
|
// Verify Adaptive Card structure
|
||||||
const card = payload.attachments[0].content;
|
const card = payload.attachments[0].content;
|
||||||
expect(card.type).toBe("AdaptiveCard");
|
expect(card.type).toBe("AdaptiveCard");
|
||||||
expect(card.version).toBe("1.4");
|
expect(card.version).toBe("1.4");
|
||||||
expect(card.body[0].type).toBe("TextBlock");
|
expect(card.body[0].type).toBe("TextBlock");
|
||||||
expect(card.body[0].text).toBe("This is a test notification from Checkmate");
|
expect(card.body[0].text).toContain("test notification");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false and logs warning on HTTP error", async () => {
|
it("returns false and logs warning on HTTP error", async () => {
|
||||||
mockPost.mockRejectedValue(new Error("Network error"));
|
gotPost.mockRejectedValue(new Error("Network error"));
|
||||||
const notification = createNotification();
|
const notification = createNotification();
|
||||||
const result = await provider.sendTestAlert(notification);
|
const result = await provider.sendTestAlert(notification);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
@@ -113,7 +122,7 @@ describe("TeamsProvider", () => {
|
|||||||
const notification = createNotification({ address: undefined });
|
const notification = createNotification({ address: undefined });
|
||||||
const result = await provider.sendMessage(notification, createMessage());
|
const result = await provider.sendMessage(notification, createMessage());
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
expect(gotPost).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends a well-formed Adaptive Card and returns true", async () => {
|
it("sends a well-formed Adaptive Card and returns true", async () => {
|
||||||
@@ -122,9 +131,9 @@ describe("TeamsProvider", () => {
|
|||||||
const result = await provider.sendMessage(notification, message);
|
const result = await provider.sendMessage(notification, message);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const [url, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||||
expect(url).toBe(notification.address);
|
expect(url).toBe(notification.address);
|
||||||
|
|
||||||
const payload = options.json;
|
const payload = options.json;
|
||||||
@@ -134,13 +143,18 @@ describe("TeamsProvider", () => {
|
|||||||
expect(card.type).toBe("AdaptiveCard");
|
expect(card.type).toBe("AdaptiveCard");
|
||||||
expect(card.version).toBe("1.4");
|
expect(card.version).toBe("1.4");
|
||||||
|
|
||||||
|
// Verify body contains expected elements
|
||||||
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
|
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
|
||||||
const factSets = card.body.filter((b: any) => b.type === "FactSet");
|
const factSets = card.body.filter((b: any) => b.type === "FactSet");
|
||||||
|
|
||||||
|
// Title block
|
||||||
expect(textBlocks[0].text).toBe(message.content.title);
|
expect(textBlocks[0].text).toBe(message.content.title);
|
||||||
expect(textBlocks[0].color).toBe("attention");
|
expect(textBlocks[0].color).toBe("attention"); // critical -> attention
|
||||||
|
|
||||||
|
// Summary block
|
||||||
expect(textBlocks[1].text).toBe(message.content.summary);
|
expect(textBlocks[1].text).toBe(message.content.summary);
|
||||||
|
|
||||||
|
// FactSet with monitor details
|
||||||
expect(factSets).toHaveLength(1);
|
expect(factSets).toHaveLength(1);
|
||||||
const facts = factSets[0].facts;
|
const facts = factSets[0].facts;
|
||||||
expect(facts).toEqual(
|
expect(facts).toEqual(
|
||||||
@@ -151,10 +165,11 @@ describe("TeamsProvider", () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Incident action button
|
||||||
expect(card.actions).toHaveLength(1);
|
expect(card.actions).toHaveLength(1);
|
||||||
expect(card.actions[0].type).toBe("Action.OpenUrl");
|
expect(card.actions[0].type).toBe("Action.OpenUrl");
|
||||||
expect(card.actions[0].title).toBe("View Incident");
|
expect(card.actions[0].title).toBe("View Incident");
|
||||||
expect(card.actions[0].url).toContain("/incidents/inc-1");
|
expect(card.actions[0].url).toContain("/infrastructure/mon-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits actions when no incident is present", async () => {
|
it("omits actions when no incident is present", async () => {
|
||||||
@@ -169,7 +184,7 @@ describe("TeamsProvider", () => {
|
|||||||
const result = await provider.sendMessage(notification, message);
|
const result = await provider.sendMessage(notification, message);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|
||||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||||
const card = options.json.attachments[0].content;
|
const card = options.json.attachments[0].content;
|
||||||
expect(card.actions).toBeUndefined();
|
expect(card.actions).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -197,7 +212,7 @@ describe("TeamsProvider", () => {
|
|||||||
|
|
||||||
await provider.sendMessage(notification, message);
|
await provider.sendMessage(notification, message);
|
||||||
|
|
||||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||||
const card = options.json.attachments[0].content;
|
const card = options.json.attachments[0].content;
|
||||||
|
|
||||||
// Title should use "warning" color
|
// Title should use "warning" color
|
||||||
@@ -207,7 +222,7 @@ describe("TeamsProvider", () => {
|
|||||||
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
|
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
|
||||||
expect(thresholdHeader).toBeDefined();
|
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).toBeDefined();
|
||||||
expect(cpuBlock.text).toContain("95%");
|
expect(cpuBlock.text).toContain("95%");
|
||||||
expect(cpuBlock.text).toContain("threshold: 80%");
|
expect(cpuBlock.text).toContain("threshold: 80%");
|
||||||
@@ -224,20 +239,20 @@ describe("TeamsProvider", () => {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const { severity, expected } of severityMap) {
|
for (const { severity, expected } of severityMap) {
|
||||||
mockPost.mockReset();
|
gotPost.mockReset();
|
||||||
mockPost.mockResolvedValue({});
|
gotPost.mockResolvedValue({});
|
||||||
|
|
||||||
const message = createMessage({ severity });
|
const message = createMessage({ severity });
|
||||||
await provider.sendMessage(notification, message);
|
await provider.sendMessage(notification, message);
|
||||||
|
|
||||||
const [, options] = mockPost.mock.calls[0] as [string, { json: any }];
|
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||||
const card = options.json.attachments[0].content;
|
const card = options.json.attachments[0].content;
|
||||||
expect(card.body[0].color).toBe(expected);
|
expect(card.body[0].color).toBe(expected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false and logs warning on HTTP error", async () => {
|
it("returns false and logs warning on HTTP error", async () => {
|
||||||
mockPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||||
const notification = createNotification();
|
const notification = createNotification();
|
||||||
const result = await provider.sendMessage(notification, createMessage());
|
const result = await provider.sendMessage(notification, createMessage());
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
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 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,889 +0,0 @@
|
|||||||
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