diff --git a/.gitignore b/.gitignore index ff828c1ec..055e3d31e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,10 @@ .VSCodeCounter *.sh mongo +timescaledb node_modules/ docs/architecture docs/reviews docs/todo -docs/frontend \ No newline at end of file +docs/frontend +docs/timescale \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 5d9d920ee..9e2a2b8cb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -37,6 +37,7 @@ "mongoose": "^8.3.3", "multer": "^1.4.5-lts.1", "nodemailer": "8.0.1", + "pg": "8.20.0", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", @@ -62,6 +63,7 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "7.0.1", "@types/papaparse": "^5.5.2", + "@types/pg": "8.20.0", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", "@types/ws": "^8.18.1", @@ -873,6 +875,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1502,6 +1505,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1546,6 +1550,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4337,6 +4342,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4497,6 +4503,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4522,6 +4529,18 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/ping": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz", @@ -4734,6 +4753,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5347,6 +5367,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5925,6 +5946,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6867,6 +6889,7 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", + "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7634,6 +7657,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7995,6 +8019,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9639,6 +9664,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12358,6 +12384,96 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12509,6 +12625,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12982,6 +13099,45 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/posthtml": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", @@ -14027,6 +14183,15 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", "license": "ISC" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -14604,6 +14769,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14772,6 +14938,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14934,6 +15101,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server/package.json b/server/package.json index 88f90c68a..639a1b9e2 100755 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "mongoose": "^8.3.3", "multer": "^1.4.5-lts.1", "nodemailer": "8.0.1", + "pg": "8.20.0", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", @@ -77,6 +78,7 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "7.0.1", "@types/papaparse": "^5.5.2", + "@types/pg": "8.20.0", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", "@types/ws": "^8.18.1", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b31c8a5e9..c944e36c2 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -46,6 +46,7 @@ import { IIncidentService, INotificationMessageBuilder, ISettingsService, + SettingsService, EnvConfig, } from "@/service/index.js"; @@ -92,9 +93,23 @@ import { MongoInvitesRepository, MongoRecoveryTokensRepository, MongoNotificationsRepository, - MongoIncidentRepository, + MongoIncidentsRepository, MongoTeamsRepository, MongoMaintenanceWindowsRepository, + MongoSettingsRepository, + TimescaleMonitorsRepository, + TimescaleChecksRepository, + TimescaleGeoChecksRepository, + TimescaleMonitorStatsRepository, + TimescaleStatusPagesRepository, + TimescaleUsersRepository, + TimescaleInvitesRepository, + TimescaleRecoveryTokensRepository, + TimescaleNotificationsRepository, + TimescaleIncidentsRepository, + TimescaleTeamsRepository, + TimescaleMaintenanceWindowsRepository, + TimescaleSettingsRepository, IMonitorsRepository, IChecksRepository, IGeoChecksRepository, @@ -110,6 +125,8 @@ import { IMaintenanceWindowsRepository, } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; +import TimescaleDB from "@/db/TimescaleDB.js"; +import { AppError } from "@/utils/AppError.js"; export type InitializedServices = { settingsService: ISettingsService; @@ -152,32 +169,81 @@ export const initializeServices = async ({ logger, envSettings, settingsService, - settingsRepository, }: { logger: ILogger; envSettings: EnvConfig; settingsService: ISettingsService; - settingsRepository: ISettingsRepository; }): Promise => { // Create DB - const db = new MongoDB(logger, envSettings); + const dbType = envSettings.dbType; + + let db: IDb | null = null; + + if (dbType === "mongodb") { + db = new MongoDB(logger, envSettings); + } else if (dbType === "timescaledb") { + db = new TimescaleDB(logger, envSettings); + } + + if (!db) { + throw new AppError({ message: "Unsupported database type", status: 500 }); + } await db.connect(); + let monitorsRepository: IMonitorsRepository; + let checksRepository: IChecksRepository; + let geoChecksRepository: IGeoChecksRepository; + let monitorStatsRepository: IMonitorStatsRepository; + let statusPagesRepository: IStatusPagesRepository; + let usersRepository: IUsersRepository; + let invitesRepository: IInvitesRepository; + let recoveryTokensRepository: IRecoveryTokensRepository; + let settingsRepository: ISettingsRepository; + let notificationsRepository: INotificationsRepository; + let incidentsRepository: IIncidentsRepository; + let teamsRepository: ITeamsRepository; + let maintenanceWindowsRepository: IMaintenanceWindowsRepository; + // Repositories - const monitorsRepository = new MongoMonitorsRepository(); - const checksRepository = new MongoChecksRepository(logger); - const geoChecksRepository = new MongoGeoChecksRepository(logger); - const monitorStatsRepository = new MongoMonitorStatsRepository(); - const statusPagesRepository = new MongoStatusPagesRepository(); - const usersRepository = new MongoUsersRepository(); - const invitesRepository = new MongoInvitesRepository(); - const recoveryTokensRepository = new MongoRecoveryTokensRepository(); - const notificationsRepository = new MongoNotificationsRepository(); - const incidentsRepository = new MongoIncidentRepository(); - const teamsRepository = new MongoTeamsRepository(); - const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + + if (dbType === "mongodb") { + monitorsRepository = new MongoMonitorsRepository(); + checksRepository = new MongoChecksRepository(logger); + geoChecksRepository = new MongoGeoChecksRepository(logger); + monitorStatsRepository = new MongoMonitorStatsRepository(); + statusPagesRepository = new MongoStatusPagesRepository(); + usersRepository = new MongoUsersRepository(); + invitesRepository = new MongoInvitesRepository(); + recoveryTokensRepository = new MongoRecoveryTokensRepository(); + settingsRepository = new MongoSettingsRepository(); + notificationsRepository = new MongoNotificationsRepository(); + incidentsRepository = new MongoIncidentsRepository(); + teamsRepository = new MongoTeamsRepository(); + maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + } else { + const pool = db.getPool(); + if (!pool) { + throw new Error("Failed to get database pool"); + } + monitorsRepository = new TimescaleMonitorsRepository(pool); + checksRepository = new TimescaleChecksRepository(pool); + geoChecksRepository = new TimescaleGeoChecksRepository(pool); + monitorStatsRepository = new TimescaleMonitorStatsRepository(pool); + statusPagesRepository = new TimescaleStatusPagesRepository(pool); + usersRepository = new TimescaleUsersRepository(pool); + invitesRepository = new TimescaleInvitesRepository(pool); + recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool); + settingsRepository = new TimescaleSettingsRepository(pool); + notificationsRepository = new TimescaleNotificationsRepository(pool); + incidentsRepository = new TimescaleIncidentsRepository(pool); + teamsRepository = new TimescaleTeamsRepository(pool); + maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool); + } + + // Inject settings repository into settings service (now that DB is connected) + (settingsService as SettingsService).setRepository(settingsRepository); // Network providers const pingProvider = new PingProvider(ping); diff --git a/server/src/controllers/geoCheckController.ts b/server/src/controllers/geoCheckController.ts index 8ac130459..96f9beeac 100644 --- a/server/src/controllers/geoCheckController.ts +++ b/server/src/controllers/geoCheckController.ts @@ -26,16 +26,6 @@ class GeoCheckController implements IGeoCheckController { const validatedParams = getChecksParamValidation.parse(req.params); const validatedQuery = getChecksQueryValidation.parse(req.query); - if (!validatedQuery.continent || validatedQuery.continent.length === 0) { - throw new AppError({ - message: "At least one continent must be specified", - service: SERVICE_NAME, - method: "getGeoChecksByMonitor", - details: { monitorId: validatedParams.monitorId }, - status: 400, - }); - } - const teamId = requireTeamId(req.user?.teamId); const result = await this.geoChecksService.getGeoChecksByMonitor({ diff --git a/server/src/db/IDb.ts b/server/src/db/IDb.ts index 4c719999f..e71e36cca 100644 --- a/server/src/db/IDb.ts +++ b/server/src/db/IDb.ts @@ -1,4 +1,7 @@ +import { Pool } from "pg"; + export interface IDb { connect(): Promise; disconnect(): Promise; + getPool(): Pool | null; } diff --git a/server/src/db/MongoDB.ts b/server/src/db/MongoDB.ts index 82d05c3a2..8aea56279 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/MongoDB.ts @@ -5,6 +5,7 @@ import { ILogger } from "@/utils/logger.js"; import { EnvConfig } from "@/service/system/settingsService.js"; const SERVICE_NAME = "MongoDB"; import { IDb } from "@/db/IDb.js"; +import { Pool } from "pg"; class MongoDB implements IDb { static SERVICE_NAME = SERVICE_NAME; @@ -74,6 +75,10 @@ class MongoDB implements IDb { }); } }; + + getPool = () => { + return null; + }; } export default MongoDB; diff --git a/server/src/db/TimescaleDB.ts b/server/src/db/TimescaleDB.ts new file mode 100644 index 000000000..b36c54292 --- /dev/null +++ b/server/src/db/TimescaleDB.ts @@ -0,0 +1,105 @@ +import { IDb } from "@/db/IDb.js"; +import { ILogger } from "@/utils/logger.js"; +import { EnvConfig } from "@/service/system/settingsService.js"; +import { Client, Pool } from "pg"; +import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js"; + +const SERVICE_NAME = "TimescaleDB"; + +class TimescaleDB implements IDb { + private pool: Pool | null = null; + + constructor( + private logger: ILogger, + private envSettings: EnvConfig + ) {} + + private ensureDatabaseExists = async (connectionString: string) => { + const url = new URL(connectionString); + const dbName = url.pathname.slice(1); // Remove leading "/" + + // Connect to the default "postgres" database + url.pathname = "/postgres"; + const client = new Client({ connectionString: url.toString() }); + await client.connect(); + + const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]); + if (result.rowCount === 0) { + await client.query(`CREATE DATABASE "${dbName}"`); + this.logger.info({ + message: `Created database "${dbName}"`, + service: SERVICE_NAME, + method: "ensureDatabaseExists", + }); + } + await client.end(); + }; + + connect = async () => { + try { + const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate"; + + await this.ensureDatabaseExists(connectionString); + + this.pool = new Pool({ connectionString }); + + const client = await this.pool.connect(); + client.release(); + + // Ensure TimescaleDB extension is enabled + await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;"); + + this.logger.info({ + message: "Connected to TimescaleDB", + service: SERVICE_NAME, + method: "connect", + }); + + await runTimescaleDBMigrations(this.pool, this.logger); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "connect", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + + disconnect = async () => { + try { + if (this.pool === null) { + return; + } + this.logger.info({ + message: "Disconnecting from TimescaleDB", + service: SERVICE_NAME, + method: "disconnect", + }); + await this.pool.end(); + this.pool = null; + this.logger.info({ + message: "Disconnected from TimescaleDB", + service: SERVICE_NAME, + method: "disconnect", + }); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "disconnect", + stack: error instanceof Error ? error.stack : undefined, + }); + } + }; + + getPool = (): Pool => { + if (this.pool === null) { + throw new Error("TimescaleDB is not connected. Call connect() first."); + } + return this.pool; + }; +} + +export default TimescaleDB; diff --git a/server/src/db/migration/timescaledb/0001_create_enums.ts b/server/src/db/migration/timescaledb/0001_create_enums.ts new file mode 100644 index 000000000..a16ec955b --- /dev/null +++ b/server/src/db/migration/timescaledb/0001_create_enums.ts @@ -0,0 +1,50 @@ +import type { Pool } from "pg"; + +export const createEnums = async (pool: Pool) => { + await pool.query(` + DO $$ BEGIN + CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + + DO $$ BEGIN + CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); +}; + +export const dropEnums = async (pool: Pool) => { + await pool.query(` + DROP TYPE IF EXISTS match_method; + DROP TYPE IF EXISTS status_page_type; + DROP TYPE IF EXISTS duration_unit; + DROP TYPE IF EXISTS incident_resolution_type; + DROP TYPE IF EXISTS user_role; + DROP TYPE IF EXISTS notification_type; + DROP TYPE IF EXISTS monitor_status; + DROP TYPE IF EXISTS monitor_type; + `); +}; diff --git a/server/src/db/migration/timescaledb/0002_create_teams.ts b/server/src/db/migration/timescaledb/0002_create_teams.ts new file mode 100644 index 000000000..905883eb2 --- /dev/null +++ b/server/src/db/migration/timescaledb/0002_create_teams.ts @@ -0,0 +1,18 @@ +import type { Pool } from "pg"; + +export const createTeams = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email); + `); +}; + +export const dropTeams = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS teams;`); +}; diff --git a/server/src/db/migration/timescaledb/0003_create_users.ts b/server/src/db/migration/timescaledb/0003_create_users.ts new file mode 100644 index 000000000..6c8f57027 --- /dev/null +++ b/server/src/db/migration/timescaledb/0003_create_users.ts @@ -0,0 +1,30 @@ +import type { Pool } from "pg"; + +export const createUsers = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar_image TEXT, + profile_image BYTEA, + profile_image_content_type TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], + check_ttl INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id); + CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + `); +}; + +export const dropUsers = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS users;`); +}; diff --git a/server/src/db/migration/timescaledb/0004_create_monitors.ts b/server/src/db/migration/timescaledb/0004_create_monitors.ts new file mode 100644 index 000000000..a4829c7f5 --- /dev/null +++ b/server/src/db/migration/timescaledb/0004_create_monitors.ts @@ -0,0 +1,69 @@ +import type { Pool } from "pg"; + +export const createMonitors = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS monitors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + name TEXT NOT NULL, + description TEXT, + type monitor_type NOT NULL, + status monitor_status NOT NULL DEFAULT 'initializing', + + -- HTTP / Website monitoring + url TEXT, + port INTEGER, + ignore_tls_errors BOOLEAN DEFAULT FALSE, + use_advanced_matching BOOLEAN DEFAULT FALSE, + json_path TEXT, + expected_value TEXT, + match_method match_method, + secret TEXT, + + -- Scheduling + interval_ms INTEGER NOT NULL DEFAULT 60000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Uptime tracking + status_window BOOLEAN[], + status_window_size INTEGER DEFAULT 5, + status_window_threshold INTEGER DEFAULT 60, + uptime_percentage DOUBLE PRECISION, + + -- Infrastructure alert thresholds + cpu_alert_threshold INTEGER, + cpu_alert_counter INTEGER, + memory_alert_threshold INTEGER, + memory_alert_counter INTEGER, + disk_alert_threshold INTEGER, + disk_alert_counter INTEGER, + temp_alert_threshold INTEGER, + temp_alert_counter INTEGER, + selected_disks TEXT[], + + -- Game monitoring + game_id TEXT, + grpc_service_name TEXT, + + -- Grouping + monitor_group TEXT, + + -- Geo checks + geo_check_enabled BOOLEAN DEFAULT FALSE, + geo_check_locations TEXT[], + geo_check_interval_ms INTEGER DEFAULT 300000, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type); + CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id); + CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status); + `); +}; + +export const dropMonitors = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS monitors;`); +}; diff --git a/server/src/db/migration/timescaledb/0005_create_notifications.ts b/server/src/db/migration/timescaledb/0005_create_notifications.ts new file mode 100644 index 000000000..2a4a065fa --- /dev/null +++ b/server/src/db/migration/timescaledb/0005_create_notifications.ts @@ -0,0 +1,30 @@ +import type { Pool } from "pg"; + +export const createNotifications = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + type notification_type NOT NULL, + notification_name TEXT, + address TEXT, + phone TEXT, + + -- Matrix-specific fields + homeserver_url TEXT, + room_id TEXT, + access_token TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id); + CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); + `); +}; + +export const dropNotifications = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS notifications;`); +}; diff --git a/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts b/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts new file mode 100644 index 000000000..d2a306322 --- /dev/null +++ b/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts @@ -0,0 +1,18 @@ +import type { Pool } from "pg"; + +export const createMonitorNotifications = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS monitor_notifications ( + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, + PRIMARY KEY (monitor_id, notification_id) + ); + + CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification + ON monitor_notifications (notification_id); + `); +}; + +export const dropMonitorNotifications = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`); +}; diff --git a/server/src/db/migration/timescaledb/0007_create_incidents.ts b/server/src/db/migration/timescaledb/0007_create_incidents.ts new file mode 100644 index 000000000..e1c990f95 --- /dev/null +++ b/server/src/db/migration/timescaledb/0007_create_incidents.ts @@ -0,0 +1,34 @@ +import type { Pool } from "pg"; + +export const createIncidents = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + status BOOLEAN, + message TEXT, + status_code INTEGER, + resolution_type incident_resolution_type, + resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, + resolved_by_email TEXT, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status); + CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status); + CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC); + CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC); + CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status); + CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status); + CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC); + `); +}; + +export const dropIncidents = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS incidents;`); +}; diff --git a/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts b/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts new file mode 100644 index 000000000..f08004873 --- /dev/null +++ b/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts @@ -0,0 +1,25 @@ +import type { Pool } from "pg"; + +export const createMonitorStats = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS monitor_stats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE, + avg_response_time DOUBLE PRECISION DEFAULT 0, + max_response_time DOUBLE PRECISION DEFAULT 0, + total_checks BIGINT DEFAULT 0, + total_up_checks BIGINT DEFAULT 0, + total_down_checks BIGINT DEFAULT 0, + uptime_percentage DOUBLE PRECISION DEFAULT 0, + last_check_timestamp TIMESTAMPTZ, + last_response_time DOUBLE PRECISION DEFAULT 0, + time_of_last_failure TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +}; + +export const dropMonitorStats = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS monitor_stats;`); +}; diff --git a/server/src/db/migration/timescaledb/0009_create_checks.ts b/server/src/db/migration/timescaledb/0009_create_checks.ts new file mode 100644 index 000000000..27753f524 --- /dev/null +++ b/server/src/db/migration/timescaledb/0009_create_checks.ts @@ -0,0 +1,105 @@ +import type { Pool } from "pg"; + +export const createChecks = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS checks ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL, + team_id UUID NOT NULL, + monitor_type monitor_type NOT NULL, + status BOOLEAN, + response_time INTEGER, + status_code INTEGER, + message TEXT, + + -- HTTP request timings + timing_start DOUBLE PRECISION, + timing_socket DOUBLE PRECISION, + timing_lookup DOUBLE PRECISION, + timing_connect DOUBLE PRECISION, + timing_secure_connect DOUBLE PRECISION, + timing_upload DOUBLE PRECISION, + timing_response DOUBLE PRECISION, + timing_end DOUBLE PRECISION, + phase_wait DOUBLE PRECISION, + phase_dns DOUBLE PRECISION, + phase_tcp DOUBLE PRECISION, + phase_tls DOUBLE PRECISION, + phase_request DOUBLE PRECISION, + phase_first_byte DOUBLE PRECISION, + phase_download DOUBLE PRECISION, + phase_total DOUBLE PRECISION, + + -- CPU metrics + cpu_physical_core INTEGER, + cpu_logical_core INTEGER, + cpu_frequency DOUBLE PRECISION, + cpu_current_frequency DOUBLE PRECISION, + cpu_temperature DOUBLE PRECISION[], + cpu_free_percent DOUBLE PRECISION, + cpu_usage_percent DOUBLE PRECISION, + + -- Memory metrics + mem_total_bytes BIGINT, + mem_available_bytes BIGINT, + mem_used_bytes BIGINT, + mem_usage_percent DOUBLE PRECISION, + + -- Host info + host_os TEXT, + host_platform TEXT, + host_kernel_version TEXT, + host_pretty_name TEXT, + + -- Capture agent info + capture_version TEXT, + capture_mode TEXT, + + -- Lighthouse / PageSpeed scores + lighthouse_performance DOUBLE PRECISION, + lighthouse_accessibility DOUBLE PRECISION, + lighthouse_best_practices DOUBLE PRECISION, + lighthouse_seo DOUBLE PRECISION, + + -- Lighthouse audit details + audit_cls_score DOUBLE PRECISION, + audit_cls_value DOUBLE PRECISION, + audit_cls_display TEXT, + audit_si_score DOUBLE PRECISION, + audit_si_value DOUBLE PRECISION, + audit_si_display TEXT, + audit_fcp_score DOUBLE PRECISION, + audit_fcp_value DOUBLE PRECISION, + audit_fcp_display TEXT, + audit_lcp_score DOUBLE PRECISION, + audit_lcp_value DOUBLE PRECISION, + audit_lcp_display TEXT, + audit_tbt_score DOUBLE PRECISION, + audit_tbt_value DOUBLE PRECISION, + audit_tbt_display TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + SELECT create_hypertable('checks', 'created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_checks_monitor_time + ON checks (monitor_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_team_time + ON checks (team_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time + ON checks (monitor_id, monitor_type, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_team_status_time + ON checks (team_id, status, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_checks_status_code + ON checks (status_code, created_at DESC); + `); +}; + +export const dropChecks = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS checks;`); +}; diff --git a/server/src/db/migration/timescaledb/0010_create_check_disks.ts b/server/src/db/migration/timescaledb/0010_create_check_disks.ts new file mode 100644 index 000000000..e77784d39 --- /dev/null +++ b/server/src/db/migration/timescaledb/0010_create_check_disks.ts @@ -0,0 +1,37 @@ +import type { Pool } from "pg"; + +export const createCheckDisks = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS check_disks ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + check_id UUID NOT NULL, + check_created_at TIMESTAMPTZ NOT NULL, + device TEXT, + mountpoint TEXT, + total_bytes BIGINT, + free_bytes BIGINT, + used_bytes BIGINT, + usage_percent DOUBLE PRECISION, + total_inodes BIGINT, + free_inodes BIGINT, + used_inodes BIGINT, + inodes_usage_percent DOUBLE PRECISION, + read_bytes BIGINT, + write_bytes BIGINT, + read_time BIGINT, + write_time BIGINT + ); + + SELECT create_hypertable('check_disks', 'check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_check_disks_check + ON check_disks (check_id, check_created_at); + `); +}; + +export const dropCheckDisks = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS check_disks;`); +}; diff --git a/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts b/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts new file mode 100644 index 000000000..4988fe58e --- /dev/null +++ b/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts @@ -0,0 +1,34 @@ +import type { Pool } from "pg"; + +export const createCheckNetworkInterfaces = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS check_network_interfaces ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + check_id UUID NOT NULL, + check_created_at TIMESTAMPTZ NOT NULL, + name TEXT, + bytes_sent BIGINT, + bytes_recv BIGINT, + packets_sent BIGINT, + packets_recv BIGINT, + err_in BIGINT, + err_out BIGINT, + drop_in BIGINT, + drop_out BIGINT, + fifo_in BIGINT, + fifo_out BIGINT + ); + + SELECT create_hypertable('check_network_interfaces', 'check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_check_net_check + ON check_network_interfaces (check_id, check_created_at); + `); +}; + +export const dropCheckNetworkInterfaces = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`); +}; diff --git a/server/src/db/migration/timescaledb/0012_create_check_errors.ts b/server/src/db/migration/timescaledb/0012_create_check_errors.ts new file mode 100644 index 000000000..c84bacac8 --- /dev/null +++ b/server/src/db/migration/timescaledb/0012_create_check_errors.ts @@ -0,0 +1,25 @@ +import type { Pool } from "pg"; + +export const createCheckErrors = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS check_errors ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + check_id UUID NOT NULL, + check_created_at TIMESTAMPTZ NOT NULL, + metrics TEXT[], + error TEXT + ); + + SELECT create_hypertable('check_errors', 'check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_check_errors_check + ON check_errors (check_id, check_created_at); + `); +}; + +export const dropCheckErrors = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS check_errors;`); +}; diff --git a/server/src/db/migration/timescaledb/0013_create_geo_checks.ts b/server/src/db/migration/timescaledb/0013_create_geo_checks.ts new file mode 100644 index 000000000..a6ee03061 --- /dev/null +++ b/server/src/db/migration/timescaledb/0013_create_geo_checks.ts @@ -0,0 +1,29 @@ +import type { Pool } from "pg"; + +export const createGeoChecks = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS geo_checks ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL, + team_id UUID NOT NULL, + monitor_type monitor_type NOT NULL, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + SELECT create_hypertable('geo_checks', 'created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time + ON geo_checks (monitor_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time + ON geo_checks (team_id, created_at DESC); + `); +}; + +export const dropGeoChecks = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS geo_checks;`); +}; diff --git a/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts b/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts new file mode 100644 index 000000000..4e0e45e1a --- /dev/null +++ b/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts @@ -0,0 +1,46 @@ +import type { Pool } from "pg"; + +export const createGeoCheckResults = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS geo_check_results ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + geo_check_id UUID NOT NULL, + geo_check_created_at TIMESTAMPTZ NOT NULL, + + -- Status + status BOOLEAN, + status_code INTEGER, + + -- Location + location_continent TEXT, + location_region TEXT, + location_country TEXT, + location_state TEXT, + location_city TEXT, + location_longitude DOUBLE PRECISION, + location_latitude DOUBLE PRECISION, + + -- Timings + timing_total DOUBLE PRECISION, + timing_dns DOUBLE PRECISION, + timing_tcp DOUBLE PRECISION, + timing_tls DOUBLE PRECISION, + timing_first_byte DOUBLE PRECISION, + timing_download DOUBLE PRECISION + ); + + SELECT create_hypertable('geo_check_results', 'geo_check_created_at', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE + ); + + CREATE INDEX IF NOT EXISTS idx_geo_results_check + ON geo_check_results (geo_check_id, geo_check_created_at); + CREATE INDEX IF NOT EXISTS idx_geo_results_country + ON geo_check_results (location_country, geo_check_created_at DESC); + `); +}; + +export const dropGeoCheckResults = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS geo_check_results;`); +}; diff --git a/server/src/db/migration/timescaledb/0015_create_invites.ts b/server/src/db/migration/timescaledb/0015_create_invites.ts new file mode 100644 index 000000000..80aca5255 --- /dev/null +++ b/server/src/db/migration/timescaledb/0015_create_invites.ts @@ -0,0 +1,22 @@ +import type { Pool } from "pg"; + +export const createInvites = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], + token TEXT NOT NULL, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry); + `); +}; + +export const dropInvites = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS invites;`); +}; diff --git a/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts b/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts new file mode 100644 index 000000000..36a9d0de3 --- /dev/null +++ b/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts @@ -0,0 +1,20 @@ +import type { Pool } from "pg"; + +export const createRecoveryTokens = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS recovery_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + token TEXT NOT NULL, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry); + `); +}; + +export const dropRecoveryTokens = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`); +}; diff --git a/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts b/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts new file mode 100644 index 000000000..190769261 --- /dev/null +++ b/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts @@ -0,0 +1,29 @@ +import type { Pool } from "pg"; + +export const createMaintenanceWindows = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS maintenance_windows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + active BOOLEAN NOT NULL DEFAULT TRUE, + name TEXT, + duration INTEGER, + duration_unit duration_unit, + repeat INTEGER, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + expiry TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id); + CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id); + CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry); + `); +}; + +export const dropMaintenanceWindows = async (pool: Pool) => { + await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`); +}; diff --git a/server/src/db/migration/timescaledb/0018_create_status_pages.ts b/server/src/db/migration/timescaledb/0018_create_status_pages.ts new file mode 100644 index 000000000..6037f6852 --- /dev/null +++ b/server/src/db/migration/timescaledb/0018_create_status_pages.ts @@ -0,0 +1,51 @@ +import type { Pool } from "pg"; + +export const createStatusPages = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS status_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + team_id UUID NOT NULL REFERENCES teams(id), + types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[], + company_name TEXT, + url TEXT NOT NULL UNIQUE, + timezone TEXT, + color TEXT DEFAULT '#4169E1', + logo_data BYTEA, + logo_content_type TEXT, + is_published BOOLEAN DEFAULT FALSE, + show_charts BOOLEAN DEFAULT FALSE, + show_uptime_percentage BOOLEAN DEFAULT FALSE, + show_admin_login_link BOOLEAN DEFAULT FALSE, + show_infrastructure BOOLEAN DEFAULT FALSE, + custom_css TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id); + CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url); + + CREATE TABLE IF NOT EXISTS status_page_monitors ( + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + PRIMARY KEY (status_page_id, monitor_id) + ); + + CREATE TABLE IF NOT EXISTS status_page_sub_monitors ( + status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, + monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + PRIMARY KEY (status_page_id, monitor_id) + ); + `); +}; + +export const dropStatusPages = async (pool: Pool) => { + await pool.query(` + DROP TABLE IF EXISTS status_page_sub_monitors; + DROP TABLE IF EXISTS status_page_monitors; + DROP TABLE IF EXISTS status_pages; + `); +}; diff --git a/server/src/db/migration/timescaledb/0019_create_app_settings.ts b/server/src/db/migration/timescaledb/0019_create_app_settings.ts new file mode 100644 index 000000000..d2d624d07 --- /dev/null +++ b/server/src/db/migration/timescaledb/0019_create_app_settings.ts @@ -0,0 +1,62 @@ +import type { Pool } from "pg"; + +export const createAppSettings = async (pool: Pool) => { + await pool.query(` + CREATE TABLE IF NOT EXISTS app_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + check_ttl INTEGER DEFAULT 30, + language TEXT, + jwt_secret TEXT, + pagespeed_api_key TEXT, + + -- SMTP / Email settings + system_email_host TEXT, + system_email_port INTEGER, + system_email_address TEXT, + system_email_password TEXT, + system_email_user TEXT, + system_email_connection_host TEXT, + system_email_tls_servername TEXT, + system_email_secure BOOLEAN, + system_email_pool BOOLEAN, + system_email_ignore_tls BOOLEAN, + system_email_require_tls BOOLEAN, + system_email_reject_unauthorized BOOLEAN, + + show_url BOOLEAN, + version TEXT, + + -- Global infrastructure thresholds + threshold_cpu_usage INTEGER, + threshold_memory_usage INTEGER, + threshold_disk_usage INTEGER, + threshold_temperature INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Enforce singleton: only one row allowed + CREATE OR REPLACE FUNCTION enforce_singleton_app_settings() + RETURNS TRIGGER AS $$ + BEGIN + IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN + RAISE EXCEPTION 'app_settings allows only one row'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings; + CREATE TRIGGER trg_singleton_app_settings + BEFORE INSERT ON app_settings + FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings(); + `); +}; + +export const dropAppSettings = async (pool: Pool) => { + await pool.query(` + DROP TABLE IF EXISTS app_settings; + DROP FUNCTION IF EXISTS enforce_singleton_app_settings(); + `); +}; diff --git a/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts b/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts new file mode 100644 index 000000000..d435ffdf9 --- /dev/null +++ b/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts @@ -0,0 +1,127 @@ +import type { Pool } from "pg"; + +export const createContinuousAggregates = async (pool: Pool) => { + // Uptime & Response Time (Hourly) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 hour', created_at) AS bucket, + COUNT(*) AS total_checks, + COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, + COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, + AVG(response_time) AS avg_response_time, + AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, + AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, + MAX(response_time) AS max_response_time, + MIN(response_time) AS min_response_time + FROM checks + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('checks_hourly', + start_offset => INTERVAL '3 hours', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`); + + // Uptime & Response Time (Daily) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 day', created_at) AS bucket, + COUNT(*) AS total_checks, + COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, + COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, + AVG(response_time) AS avg_response_time, + AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, + AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, + MAX(response_time) AS max_response_time + FROM checks + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('checks_daily', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 day', + schedule_interval => INTERVAL '1 day', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`); + + // Infrastructure Metrics (Hourly) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 hour', created_at) AS bucket, + AVG(cpu_usage_percent) AS avg_cpu, + MAX(cpu_usage_percent) AS max_cpu, + AVG(mem_usage_percent) AS avg_memory, + MAX(mem_usage_percent) AS max_memory, + COUNT(*) AS sample_count + FROM checks + WHERE monitor_type = 'hardware' + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('hardware_hourly', + start_offset => INTERVAL '3 hours', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`); + + // PageSpeed Scores (Daily) + await pool.query(` + CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily + WITH (timescaledb.continuous) AS + SELECT + monitor_id, + time_bucket('1 day', created_at) AS bucket, + AVG(lighthouse_performance) AS avg_performance, + AVG(lighthouse_accessibility) AS avg_accessibility, + AVG(lighthouse_best_practices) AS avg_best_practices, + AVG(lighthouse_seo) AS avg_seo, + COUNT(*) AS sample_count + FROM checks + WHERE monitor_type = 'pagespeed' + GROUP BY monitor_id, bucket + WITH NO DATA; + `); + await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`); + await pool.query(` + SELECT add_continuous_aggregate_policy('pagespeed_daily', + start_offset => INTERVAL '3 days', + end_offset => INTERVAL '1 day', + schedule_interval => INTERVAL '1 day', + if_not_exists => TRUE + ); + `); + await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`); +}; + +export const dropContinuousAggregates = async (pool: Pool) => { + await pool.query(` + DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE; + DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE; + DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE; + DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE; + `); +}; diff --git a/server/src/db/migration/timescaledb/0021_create_retention_compression.ts b/server/src/db/migration/timescaledb/0021_create_retention_compression.ts new file mode 100644 index 000000000..95230e248 --- /dev/null +++ b/server/src/db/migration/timescaledb/0021_create_retention_compression.ts @@ -0,0 +1,135 @@ +import type { Pool } from "pg"; + +export const createRetentionCompression = async (pool: Pool) => { + // -- Compression policies -- + + // Checks + await pool.query(` + ALTER TABLE checks SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'monitor_id', + timescaledb.compress_orderby = 'created_at DESC' + ); + SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE); + `); + + // Check child tables + await pool.query(` + ALTER TABLE check_disks SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'check_id', + timescaledb.compress_orderby = 'check_created_at DESC' + ); + SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE); + `); + + await pool.query(` + ALTER TABLE check_network_interfaces SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'check_id', + timescaledb.compress_orderby = 'check_created_at DESC' + ); + SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE); + `); + + await pool.query(` + ALTER TABLE check_errors SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'check_id', + timescaledb.compress_orderby = 'check_created_at DESC' + ); + SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE); + `); + + // Geo checks + await pool.query(` + ALTER TABLE geo_checks SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'monitor_id', + timescaledb.compress_orderby = 'created_at DESC' + ); + SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE); + `); + + await pool.query(` + ALTER TABLE geo_check_results SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'geo_check_id', + timescaledb.compress_orderby = 'geo_check_created_at DESC' + ); + SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE); + `); + + // -- Retention policies (default 30 days) -- + + await pool.query(` + SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE); + SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE); + `); + + // -- Dynamic retention helper function -- + + await pool.query(` + CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER) + RETURNS VOID AS $$ + DECLARE + ttl_interval INTERVAL; + BEGIN + ttl_interval := (new_ttl_days || ' days')::INTERVAL; + + PERFORM remove_retention_policy('checks', if_exists => true); + PERFORM remove_retention_policy('check_disks', if_exists => true); + PERFORM remove_retention_policy('check_network_interfaces', if_exists => true); + PERFORM remove_retention_policy('check_errors', if_exists => true); + PERFORM remove_retention_policy('geo_checks', if_exists => true); + PERFORM remove_retention_policy('geo_check_results', if_exists => true); + + PERFORM add_retention_policy('checks', ttl_interval); + PERFORM add_retention_policy('check_disks', ttl_interval); + PERFORM add_retention_policy('check_network_interfaces', ttl_interval); + PERFORM add_retention_policy('check_errors', ttl_interval); + PERFORM add_retention_policy('geo_checks', ttl_interval); + PERFORM add_retention_policy('geo_check_results', ttl_interval); + END; + $$ LANGUAGE plpgsql; + `); +}; + +export const dropRetentionCompression = async (pool: Pool) => { + // Remove retention policies + await pool.query(` + SELECT remove_retention_policy('checks', if_exists => true); + SELECT remove_retention_policy('check_disks', if_exists => true); + SELECT remove_retention_policy('check_network_interfaces', if_exists => true); + SELECT remove_retention_policy('check_errors', if_exists => true); + SELECT remove_retention_policy('geo_checks', if_exists => true); + SELECT remove_retention_policy('geo_check_results', if_exists => true); + `); + + // Remove compression policies + await pool.query(` + SELECT remove_compression_policy('checks', if_exists => true); + SELECT remove_compression_policy('check_disks', if_exists => true); + SELECT remove_compression_policy('check_network_interfaces', if_exists => true); + SELECT remove_compression_policy('check_errors', if_exists => true); + SELECT remove_compression_policy('geo_checks', if_exists => true); + SELECT remove_compression_policy('geo_check_results', if_exists => true); + `); + + // Disable compression on tables + await pool.query(` + ALTER TABLE checks SET (timescaledb.compress = false); + ALTER TABLE check_disks SET (timescaledb.compress = false); + ALTER TABLE check_network_interfaces SET (timescaledb.compress = false); + ALTER TABLE check_errors SET (timescaledb.compress = false); + ALTER TABLE geo_checks SET (timescaledb.compress = false); + ALTER TABLE geo_check_results SET (timescaledb.compress = false); + `); + + // Drop dynamic retention function + await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`); +}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts new file mode 100644 index 000000000..14084ebf3 --- /dev/null +++ b/server/src/db/migration/timescaledb/index.ts @@ -0,0 +1,148 @@ +import type { Pool } from "pg"; +import type { ILogger } from "@/utils/logger.js"; +import { createEnums, dropEnums } from "./0001_create_enums.js"; +import { createTeams, dropTeams } from "./0002_create_teams.js"; +import { createUsers, dropUsers } from "./0003_create_users.js"; +import { createMonitors, dropMonitors } from "./0004_create_monitors.js"; +import { createNotifications, dropNotifications } from "./0005_create_notifications.js"; +import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js"; +import { createIncidents, dropIncidents } from "./0007_create_incidents.js"; +import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js"; +import { createChecks, dropChecks } from "./0009_create_checks.js"; +import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js"; +import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js"; +import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js"; +import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js"; +import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js"; +import { createInvites, dropInvites } from "./0015_create_invites.js"; +import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js"; +import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js"; +import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js"; +import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; +import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; +import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; + +const SERVICE_NAME = "TimescaleDB Migrations"; + +type MigrationEntry = { + name: string; + up: (pool: Pool) => Promise; + down: (pool: Pool) => Promise; +}; + +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 }; diff --git a/server/src/index.ts b/server/src/index.ts index 2286d3887..83698b45c 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,16 +1,14 @@ import { initializeServices } from "./config/services.js"; import { initializeControllers } from "./config/controllers.js"; import { createApp } from "./app.js"; -import { initShutdownListener } from "./shutdown.js"; -import { validateEnv } from "./validation/envValidation.js"; +import { initShutdownListener } from "@/shutdown.js"; +import { validateEnv } from "@/validation/envValidation.js"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; -import { runMigrations } from "./db/migration/index.js"; import Logger, { ILogger } from "@/utils/logger.js"; import { SettingsService } from "@/service/index.js"; -import { MongoSettingsRepository } from "./repositories/index.js"; const SERVICE_NAME = "Server"; let logger: ILogger; @@ -25,19 +23,15 @@ const startApp = async () => { const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8")); const frontendPath = path.join(__dirname, "..", "public"); - // Create services - const settingsRepository = new MongoSettingsRepository(); - const settingsService = new SettingsService(settingsRepository, env); - + // Create settings service (env only — DB repository injected after connect) + const settingsService = new SettingsService(env); const envSettings = settingsService.loadSettings(); // Create logger logger = new Logger({ envSettings }); - // Initialize services - const services = await initializeServices({ logger, envSettings, settingsService, settingsRepository }); - - await runMigrations(logger); + // Initialize services (connects DB, creates repositories, injects settingsRepository) + const services = await initializeServices({ logger, envSettings, settingsService }); // Initialize controllers const controllers = initializeControllers(services); diff --git a/server/src/middleware/handleErrors.ts b/server/src/middleware/handleErrors.ts index be9d9ea1e..ff591484f 100755 --- a/server/src/middleware/handleErrors.ts +++ b/server/src/middleware/handleErrors.ts @@ -4,14 +4,14 @@ import { AppError } from "@/utils/AppError.js"; const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => { const status = error instanceof AppError ? error.status || 500 : 500; - const message = error instanceof AppError ? error.message : "Server error"; + const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error"; const service = error instanceof AppError ? error.service : "unknownService"; const method = error instanceof AppError ? error.method : "unknownMethod"; logger.error({ message: message, service: service, method: method, - stack: error instanceof AppError ? error.stack : undefined, + stack: error instanceof Error ? error.stack : undefined, details: error instanceof AppError ? error.details : undefined, }); res.status(status).json({ diff --git a/server/src/repositories/checks/TimescaleChecksRepository.ts b/server/src/repositories/checks/TimescaleChecksRepository.ts new file mode 100644 index 000000000..466be0f71 --- /dev/null +++ b/server/src/repositories/checks/TimescaleChecksRepository.ts @@ -0,0 +1,1012 @@ +import type { Pool } from "pg"; +import type { IChecksRepository } from "@/repositories/checks/IChecksRepository.js"; +import type { + Check, + CheckAudits, + CheckCaptureInfo, + CheckCpuInfo, + CheckDiskInfo, + CheckErrorInfo, + CheckHostInfo, + CheckMemoryInfo, + CheckNetworkInterfaceInfo, + ChecksQueryResult, + ChecksSummary, + MonitorType, + PageSpeedChecksResult, + HardwareChecksResult, + UptimeChecksResult, + GotTimings, +} from "@/types/index.js"; +import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js"; +import { getDateForRange } from "@/utils/dataUtils.js"; + +// Map MongoDB dateString formats to TimescaleDB time_bucket intervals +const dateStringToBucket = (dateString: string): string => { + if (dateString.includes("%M")) return "1 minute"; + if (dateString.includes("%H")) return "1 hour"; + return "1 day"; +}; + +export class TimescaleChecksRepository implements IChecksRepository { + constructor(private pool: Pool) {} + + // Returns correct CA table name for the given bucket interval and query type + // Or returns null if no CA is available (fall back to raw checks table). + private getCaTable(bucket: string, type: "uptime" | "hardware" | "pagespeed"): string | null { + if (type === "uptime" && bucket === "1 hour") return "checks_hourly"; + if (type === "uptime" && bucket === "1 day") return "checks_daily"; + if (type === "hardware" && bucket === "1 hour") return "hardware_hourly"; + if (type === "pagespeed" && bucket === "1 day") return "pagespeed_daily"; + return null; + } + + create = async (check: Check): Promise => { + const row = await this.insertCheck(check); + if (!row) { + throw new Error("Failed to create check"); + } + + if (check.disk?.length) { + await this.insertDisks(row.id, row.created_at, check.disk); + } + if (check.net?.length) { + await this.insertNetworkInterfaces(row.id, row.created_at, check.net); + } + if (check.errors?.length) { + await this.insertErrors(row.id, row.created_at, check.errors); + } + + return this.toEntity(row, check.disk, check.errors, check.net); + }; + + createChecks = async (checks: Check[]): Promise => { + const results: Check[] = []; + for (const check of checks) { + results.push(await this.create(check)); + } + return results; + }; + + findByMonitorId = async ( + monitorId: string, + sortOrder: string, + dateRange: string, + filter: string | undefined, + page: number, + rowsPerPage: number, + status: boolean | undefined + ): Promise => { + const { conditions, values, paramIndex } = this.buildCheckFilters({ monitorId, dateRange, filter, status }, "monitor_id"); + + const direction = sortOrder === "asc" ? "ASC" : "DESC"; + const offset = page && rowsPerPage ? page * rowsPerPage : 0; + + const [countResult, checksResult] = await Promise.all([ + this.pool.query(`SELECT COUNT(*)::int AS count FROM checks WHERE ${conditions.join(" AND ")}`, values), + this.pool.query( + `SELECT * FROM checks WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...values, rowsPerPage, offset] + ), + ]); + + return { + checksCount: countResult.rows[0].count, + checks: await this.populateChildData(checksResult.rows), + }; + }; + + findByTeamId = async ( + sortOrder: string, + dateRange: string, + filter: string | undefined, + page: number, + rowsPerPage: number, + teamId: string + ): Promise => { + const { conditions, values, paramIndex } = this.buildCheckFilters({ teamId, dateRange, filter }, "team_id"); + + const direction = sortOrder === "asc" ? "ASC" : "DESC"; + const offset = page && rowsPerPage ? page * rowsPerPage : 0; + + const [countResult, checksResult] = await Promise.all([ + this.pool.query(`SELECT COUNT(*)::int AS count FROM checks WHERE ${conditions.join(" AND ")}`, values), + this.pool.query( + `SELECT * FROM checks WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...values, rowsPerPage, offset] + ), + ]); + + return { + checksCount: countResult.rows[0].count, + checks: await this.populateChildData(checksResult.rows), + }; + }; + + findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { + if (!monitorIds.length) { + return {}; + } + const limitPerMonitor = options?.limitPerMonitor ?? 25; + const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const allResult = await this.pool.query( + `SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY created_at DESC) AS rn + FROM checks + WHERE monitor_id = ANY($1) AND created_at >= $2 + ) sub + WHERE rn <= $3 + ORDER BY monitor_id, created_at DESC`, + [monitorIds, dateFilter, limitPerMonitor] + ); + + const checks = await this.populateChildData(allResult.rows); + + const mapped: LatestChecksMap = {}; + for (let i = 0; i < allResult.rows.length; i++) { + const mid = allResult.rows[i].monitor_id; + if (!mapped[mid]) { + mapped[mid] = []; + } + const check = checks[i]; + if (check !== undefined) { + mapped[mid].push(check); + } + } + // Ensure all requested IDs have an entry + for (const id of monitorIds) { + if (!mapped[id]) { + mapped[id] = []; + } + } + return mapped; + }; + + findByDateRangeAndMonitorId = async ( + monitorId: string, + startDate: Date, + endDate: Date, + dateString: string, + options?: { type?: MonitorType } + ): Promise => { + if (options?.type === "hardware") { + return this.findHardwareDateRangeChecks(monitorId, startDate, endDate, dateString); + } + if (options?.type === "pagespeed") { + return this.findPageSpeedDateRangeChecks(monitorId, startDate, endDate, dateString); + } + return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorId, startDate, endDate, dateString); + }; + + findSummaryByTeamId = async (teamId: string, dateRange: string): Promise => { + const rangeDate = getDateForRange(dateRange); + const conditions: string[] = ["team_id = $1"]; + const values: unknown[] = [teamId]; + + if (rangeDate) { + conditions.push("created_at >= $2"); + values.push(rangeDate); + } + + const result = await this.pool.query( + `SELECT + COUNT(*)::int AS "totalChecks", + COUNT(*) FILTER (WHERE status = FALSE)::int AS "downChecks" + FROM checks WHERE ${conditions.join(" AND ")}`, + values + ); + + return result.rows[0] ?? { totalChecks: 0, downChecks: 0 }; + }; + + deleteByMonitorId = async (monitorId: string): Promise => { + const result = await this.pool.query(`DELETE FROM checks WHERE monitor_id = $1`, [monitorId]); + return result.rowCount ?? 0; + }; + + deleteByTeamId = async (teamId: string): Promise => { + const result = await this.pool.query(`DELETE FROM checks WHERE team_id = $1`, [teamId]); + return result.rowCount ?? 0; + }; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + if (!monitorIds.length) { + const result = await this.pool.query(`DELETE FROM checks`); + return result.rowCount ?? 0; + } + const result = await this.pool.query(`DELETE FROM checks WHERE monitor_id != ALL($1)`, [monitorIds]); + return result.rowCount ?? 0; + }; + + deleteOlderThan = async (date: Date): Promise => { + const result = await this.pool.query(`DELETE FROM checks WHERE created_at < $1`, [date]); + return result.rowCount ?? 0; + }; + + private populateChildData = async (checkRows: Record[]): Promise => { + if (!checkRows.length) return []; + + const checkIds = checkRows.map((r) => r.id as string); + + const [disksResult, netsResult, errsResult] = await Promise.all([ + this.pool.query( + `SELECT check_id, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, + total_inodes, free_inodes, used_inodes, inodes_usage_percent, + read_bytes, write_bytes, read_time, write_time + FROM check_disks WHERE check_id = ANY($1)`, + [checkIds] + ), + this.pool.query( + `SELECT check_id, name, bytes_sent, bytes_recv, packets_sent, packets_recv, + err_in, err_out, drop_in, drop_out, fifo_in, fifo_out + FROM check_network_interfaces WHERE check_id = ANY($1)`, + [checkIds] + ), + this.pool.query( + `SELECT check_id, metrics, error + FROM check_errors WHERE check_id = ANY($1)`, + [checkIds] + ), + ]); + + const diskMap = new Map(); + for (const d of disksResult.rows) { + const key = d.check_id as string; + if (!diskMap.has(key)) diskMap.set(key, []); + diskMap.get(key)!.push({ + device: d.device, + mountpoint: d.mountpoint, + total_bytes: Number(d.total_bytes), + free_bytes: Number(d.free_bytes), + used_bytes: Number(d.used_bytes), + usage_percent: d.usage_percent, + total_inodes: Number(d.total_inodes), + free_inodes: Number(d.free_inodes), + used_inodes: Number(d.used_inodes), + inodes_usage_percent: d.inodes_usage_percent, + read_bytes: Number(d.read_bytes), + write_bytes: Number(d.write_bytes), + read_time: Number(d.read_time), + write_time: Number(d.write_time), + }); + } + + const netMap = new Map(); + for (const n of netsResult.rows) { + const key = n.check_id as string; + if (!netMap.has(key)) netMap.set(key, []); + netMap.get(key)!.push({ + name: n.name, + bytes_sent: Number(n.bytes_sent), + bytes_recv: Number(n.bytes_recv), + packets_sent: Number(n.packets_sent), + packets_recv: Number(n.packets_recv), + err_in: Number(n.err_in), + err_out: Number(n.err_out), + drop_in: Number(n.drop_in), + drop_out: Number(n.drop_out), + fifo_in: Number(n.fifo_in), + fifo_out: Number(n.fifo_out), + }); + } + + const errMap = new Map(); + for (const e of errsResult.rows) { + const key = e.check_id as string; + if (!errMap.has(key)) errMap.set(key, []); + errMap.get(key)!.push({ metric: e.metrics ?? [], err: e.error ?? "" }); + } + + return checkRows.map((row) => { + const id = row.id as string; + return this.toEntity(row, diskMap.get(id) ?? [], errMap.get(id) ?? [], netMap.get(id) ?? []); + }); + }; + + private insertCheck = async (check: Check) => { + const result = await this.pool.query( + `INSERT INTO checks ( + monitor_id, team_id, monitor_type, status, response_time, status_code, message, + timing_start, timing_socket, timing_lookup, timing_connect, timing_secure_connect, + timing_upload, timing_response, timing_end, + phase_wait, phase_dns, phase_tcp, phase_tls, phase_request, phase_first_byte, phase_download, phase_total, + cpu_physical_core, cpu_logical_core, cpu_frequency, cpu_current_frequency, cpu_temperature, + cpu_free_percent, cpu_usage_percent, + mem_total_bytes, mem_available_bytes, mem_used_bytes, mem_usage_percent, + host_os, host_platform, host_kernel_version, host_pretty_name, + capture_version, capture_mode, + lighthouse_performance, lighthouse_accessibility, lighthouse_best_practices, lighthouse_seo, + audit_cls_score, audit_cls_value, audit_cls_display, + audit_si_score, audit_si_value, audit_si_display, + audit_fcp_score, audit_fcp_value, audit_fcp_display, + audit_lcp_score, audit_lcp_value, audit_lcp_display, + audit_tbt_score, audit_tbt_value, audit_tbt_display + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23, + $24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43,$44, + $45,$46,$47,$48,$49,$50,$51,$52,$53,$54,$55,$56,$57,$58,$59 + ) RETURNING *`, + [ + check.metadata.monitorId, + check.metadata.teamId, + check.metadata.type, + check.status, + check.responseTime, + check.statusCode, + check.message, + check.timings?.start ?? null, + check.timings?.socket ?? null, + check.timings?.lookup ?? null, + check.timings?.connect ?? null, + check.timings?.secureConnect ?? null, + check.timings?.upload ?? null, + check.timings?.response ?? null, + check.timings?.end ?? null, + check.timings?.phases?.wait ?? null, + check.timings?.phases?.dns ?? null, + check.timings?.phases?.tcp ?? null, + check.timings?.phases?.tls ?? null, + check.timings?.phases?.request ?? null, + check.timings?.phases?.firstByte ?? null, + check.timings?.phases?.download ?? null, + check.timings?.phases?.total ?? null, + check.cpu?.physical_core ?? null, + check.cpu?.logical_core ?? null, + check.cpu?.frequency ?? null, + check.cpu?.current_frequency ?? null, + check.cpu?.temperature ?? null, + check.cpu?.free_percent ?? null, + check.cpu?.usage_percent ?? null, + check.memory?.total_bytes ?? null, + check.memory?.available_bytes ?? null, + check.memory?.used_bytes ?? null, + check.memory?.usage_percent ?? null, + check.host?.os ?? null, + check.host?.platform ?? null, + check.host?.kernel_version ?? null, + check.host?.pretty_name ?? null, + check.capture?.version ?? null, + check.capture?.mode ?? null, + check.performance ?? null, + check.accessibility ?? null, + check.bestPractices ?? null, + check.seo ?? null, + check.audits?.cls?.score ?? null, + check.audits?.cls?.numericValue ?? null, + check.audits?.cls?.displayValue ?? null, + check.audits?.si?.score ?? null, + check.audits?.si?.numericValue ?? null, + check.audits?.si?.displayValue ?? null, + check.audits?.fcp?.score ?? null, + check.audits?.fcp?.numericValue ?? null, + check.audits?.fcp?.displayValue ?? null, + check.audits?.lcp?.score ?? null, + check.audits?.lcp?.numericValue ?? null, + check.audits?.lcp?.displayValue ?? null, + check.audits?.tbt?.score ?? null, + check.audits?.tbt?.numericValue ?? null, + check.audits?.tbt?.displayValue ?? null, + ] + ); + return result.rows[0]; + }; + + private insertDisks = async (checkId: string, checkCreatedAt: Date, disks: CheckDiskInfo[]) => { + for (const disk of disks) { + await this.pool.query( + `INSERT INTO check_disks (check_id, check_created_at, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, + total_inodes, free_inodes, used_inodes, inodes_usage_percent, read_bytes, write_bytes, read_time, write_time) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + [ + checkId, + checkCreatedAt, + disk.device, + disk.mountpoint, + disk.total_bytes, + disk.free_bytes, + disk.used_bytes, + disk.usage_percent, + disk.total_inodes, + disk.free_inodes, + disk.used_inodes, + disk.inodes_usage_percent, + disk.read_bytes, + disk.write_bytes, + disk.read_time, + disk.write_time, + ] + ); + } + }; + + private insertNetworkInterfaces = async (checkId: string, checkCreatedAt: Date, nets: CheckNetworkInterfaceInfo[]) => { + for (const net of nets) { + await this.pool.query( + `INSERT INTO check_network_interfaces (check_id, check_created_at, name, bytes_sent, bytes_recv, packets_sent, packets_recv, + err_in, err_out, drop_in, drop_out, fifo_in, fifo_out) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + [ + checkId, + checkCreatedAt, + net.name, + net.bytes_sent, + net.bytes_recv, + net.packets_sent, + net.packets_recv, + net.err_in, + net.err_out, + net.drop_in, + net.drop_out, + net.fifo_in, + net.fifo_out, + ] + ); + } + }; + + private insertErrors = async (checkId: string, checkCreatedAt: Date, errors: CheckErrorInfo[]) => { + for (const err of errors) { + await this.pool.query( + `INSERT INTO check_errors (check_id, check_created_at, metrics, error) + VALUES ($1,$2,$3,$4)`, + [checkId, checkCreatedAt, err.metric, err.err] + ); + } + }; + + private buildCheckFilters = ( + opts: { monitorId?: string; teamId?: string; dateRange?: string; filter?: string; status?: boolean }, + idColumn: string + ) => { + const conditions: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (opts.monitorId) { + conditions.push(`${idColumn} = $${paramIndex++}`); + values.push(opts.monitorId); + } else if (opts.teamId) { + conditions.push(`${idColumn} = $${paramIndex++}`); + values.push(opts.teamId); + } + + if (opts.dateRange) { + const rangeDate = getDateForRange(opts.dateRange); + if (rangeDate) { + conditions.push(`created_at >= $${paramIndex++}`); + values.push(rangeDate); + } + } + + // Filter overwrites status (matching Mongo behavior where filter sets matchStage.status) + let statusApplied = false; + if (opts.filter !== undefined) { + switch (opts.filter) { + case "all": + break; + case "up": + conditions.push(`status = TRUE`); + statusApplied = true; + break; + case "down": + conditions.push(`status = FALSE`); + statusApplied = true; + break; + case "resolve": + conditions.push(`status = FALSE`); + conditions.push(`status_code = 5000`); + statusApplied = true; + break; + default: + break; + } + } + + if (!statusApplied && opts.status !== undefined) { + conditions.push(`status = $${paramIndex++}`); + values.push(opts.status); + } + + return { conditions, values, paramIndex }; + }; + + private findUptimeDateRangeChecks = async ( + monitorType: Exclude, + monitorId: string, + startDate: Date, + endDate: Date, + dateString: string + ): Promise => { + const bucket = dateStringToBucket(dateString); + const caTable = this.getCaTable(bucket, "uptime"); + + if (caTable) { + return this.findUptimeFromCa(monitorType, monitorId, startDate, endDate, caTable); + } + return this.findUptimeFromRaw(monitorType, monitorId, startDate, endDate, bucket); + }; + + private findUptimeFromCa = async ( + monitorType: Exclude, + monitorId: string, + startDate: Date, + endDate: Date, + caTable: string + ): Promise => { + const result = await this.pool.query( + `SELECT bucket AS bucket_date, total_checks::int, up_checks::int, down_checks::int, + avg_response_time, avg_up_response_time, avg_down_response_time + FROM ${caTable} + WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 + ORDER BY bucket`, + [monitorId, startDate, endDate] + ); + + let totalChecks = 0; + let totalUp = 0; + let weightedResponseTime = 0; + + const groupedChecks = result.rows.map((row) => { + const count = Number(row.total_checks); + const up = Number(row.up_checks); + const avg = Number(row.avg_response_time ?? 0); + totalChecks += count; + totalUp += up; + weightedResponseTime += avg * count; + return { + bucketDate: (row.bucket_date as Date).toISOString(), + avgResponseTime: avg, + totalChecks: count, + }; + }); + + const groupedUpChecks = result.rows + .filter((row) => Number(row.up_checks) > 0) + .map((row) => ({ + bucketDate: (row.bucket_date as Date).toISOString(), + avgResponseTime: Number(row.avg_up_response_time ?? 0), + totalChecks: Number(row.up_checks), + })); + + const groupedDownChecks = result.rows + .filter((row) => Number(row.down_checks) > 0) + .map((row) => ({ + bucketDate: (row.bucket_date as Date).toISOString(), + avgResponseTime: Number(row.avg_down_response_time ?? 0), + totalChecks: Number(row.down_checks), + })); + + return { + monitorType, + groupedChecks, + groupedUpChecks, + groupedDownChecks, + uptimePercentage: totalChecks > 0 ? totalUp / totalChecks : 0, + avgResponseTime: totalChecks > 0 ? weightedResponseTime / totalChecks : 0, + }; + }; + + private findUptimeFromRaw = async ( + monitorType: Exclude, + monitorId: string, + startDate: Date, + endDate: Date, + bucket: string + ): Promise => { + const [uptimeResult, groupedResult, upResult, downResult] = await Promise.all([ + this.pool.query( + `SELECT + COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, + COUNT(*) AS total_checks, + AVG(response_time) AS avg_response_time + FROM checks WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3`, + [monitorId, startDate, endDate] + ), + this.pool.query( + `SELECT + time_bucket($1::interval, created_at) AS bucket_date, + AVG(response_time) AS "avgResponseTime", + COUNT(*)::int AS "totalChecks" + FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 + GROUP BY bucket_date ORDER BY bucket_date`, + [bucket, monitorId, startDate, endDate] + ), + this.pool.query( + `SELECT + time_bucket($1::interval, created_at) AS bucket_date, + AVG(response_time) AS "avgResponseTime", + COUNT(*)::int AS "totalChecks" + FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 AND status = TRUE + GROUP BY bucket_date ORDER BY bucket_date`, + [bucket, monitorId, startDate, endDate] + ), + this.pool.query( + `SELECT + time_bucket($1::interval, created_at) AS bucket_date, + AVG(response_time) AS "avgResponseTime", + COUNT(*)::int AS "totalChecks" + FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 AND status = FALSE + GROUP BY bucket_date ORDER BY bucket_date`, + [bucket, monitorId, startDate, endDate] + ), + ]); + + const stats = uptimeResult.rows[0]; + const totalChecks = Number(stats?.total_checks ?? 0); + const upChecks = Number(stats?.up_checks ?? 0); + + const formatGrouped = (rows: Array>) => + rows.map((row) => ({ + bucketDate: (row.bucket_date as Date).toISOString(), + avgResponseTime: Number(row.avgResponseTime ?? 0), + totalChecks: Number(row.totalChecks ?? 0), + })); + + return { + monitorType, + groupedChecks: formatGrouped(groupedResult.rows), + groupedUpChecks: formatGrouped(upResult.rows), + groupedDownChecks: formatGrouped(downResult.rows), + uptimePercentage: totalChecks > 0 ? upChecks / totalChecks : 0, + avgResponseTime: Number(stats?.avg_response_time ?? 0), + }; + }; + + private findHardwareDateRangeChecks = async ( + monitorId: string, + startDate: Date, + endDate: Date, + dateString: string + ): Promise => { + const bucket = dateStringToBucket(dateString); + const caTable = this.getCaTable(bucket, "hardware"); + + // Build the metrics query: use hardware_hourly CA for cpu/mem when available, + // but temperature always requires the raw table + const metricsQuery = caTable + ? this.pool.query( + `SELECT bucket AS bucket_date, avg_cpu, avg_memory + FROM ${caTable} + WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 + ORDER BY bucket`, + [monitorId, startDate, endDate] + ) + : this.pool.query( + `SELECT + time_bucket($1::interval, created_at) AS bucket_date, + AVG(cpu_usage_percent) AS avg_cpu, + AVG(mem_usage_percent) AS avg_memory + FROM checks + WHERE monitor_id = $2 AND monitor_type = 'hardware' AND created_at >= $3 AND created_at <= $4 + GROUP BY bucket_date ORDER BY bucket_date`, + [bucket, monitorId, startDate, endDate] + ); + + // Temperature query always hits raw table (not in any CA) + const tempQuery = this.pool.query( + `WITH temp_avg AS ( + SELECT time_bucket($1::interval, c.created_at) AS bucket_date, idx, AVG(val) AS avg_val + FROM checks c, unnest(c.cpu_temperature) WITH ORDINALITY AS t(val, idx) + WHERE c.monitor_id = $2 AND c.monitor_type = 'hardware' AND c.created_at >= $3 AND c.created_at <= $4 + GROUP BY bucket_date, idx + ) + SELECT bucket_date, array_agg(avg_val ORDER BY idx) AS avg_temperature + FROM temp_avg + GROUP BY bucket_date + ORDER BY bucket_date`, + [bucket, monitorId, startDate, endDate] + ); + + const diskQuery = this.pool.query( + `SELECT + time_bucket($1::interval, d.check_created_at) AS bucket_date, + d.device AS name, + AVG(d.read_bytes) AS "readSpeed", + AVG(d.write_bytes) AS "writeSpeed", + AVG(d.total_bytes) AS "totalBytes", + AVG(d.free_bytes) AS "freeBytes", + AVG(d.usage_percent) AS "usagePercent" + FROM check_disks d + WHERE d.check_id IN ( + SELECT id FROM checks + WHERE monitor_id = $2 AND monitor_type = 'hardware' AND created_at >= $3 AND created_at <= $4 + ) + GROUP BY bucket_date, d.device + ORDER BY bucket_date, d.device`, + [bucket, monitorId, startDate, endDate] + ); + + // Batched network query across all buckets (eliminates N+1) + const netQuery = this.pool.query( + `WITH bounded AS ( + SELECT + time_bucket($1::interval, c.created_at) AS bucket_date, + n.name, + FIRST_VALUE(n.bytes_sent) OVER w AS first_bytes_sent, + LAST_VALUE(n.bytes_sent) OVER w AS last_bytes_sent, + FIRST_VALUE(n.bytes_recv) OVER w AS first_bytes_recv, + LAST_VALUE(n.bytes_recv) OVER w AS last_bytes_recv, + FIRST_VALUE(n.packets_sent) OVER w AS first_packets_sent, + LAST_VALUE(n.packets_sent) OVER w AS last_packets_sent, + FIRST_VALUE(n.packets_recv) OVER w AS first_packets_recv, + LAST_VALUE(n.packets_recv) OVER w AS last_packets_recv, + FIRST_VALUE(n.err_in) OVER w AS first_err_in, + LAST_VALUE(n.err_in) OVER w AS last_err_in, + FIRST_VALUE(n.err_out) OVER w AS first_err_out, + LAST_VALUE(n.err_out) OVER w AS last_err_out, + FIRST_VALUE(n.drop_in) OVER w AS first_drop_in, + LAST_VALUE(n.drop_in) OVER w AS last_drop_in, + FIRST_VALUE(n.drop_out) OVER w AS first_drop_out, + LAST_VALUE(n.drop_out) OVER w AS last_drop_out, + FIRST_VALUE(n.fifo_in) OVER w AS first_fifo_in, + LAST_VALUE(n.fifo_in) OVER w AS last_fifo_in, + FIRST_VALUE(n.fifo_out) OVER w AS first_fifo_out, + LAST_VALUE(n.fifo_out) OVER w AS last_fifo_out, + FIRST_VALUE(c.created_at) OVER w AS first_time, + LAST_VALUE(c.created_at) OVER w AS last_time + FROM check_network_interfaces n + JOIN checks c ON c.id = n.check_id + WHERE c.monitor_id = $2 AND c.monitor_type = 'hardware' + AND c.created_at >= $3 AND c.created_at <= $4 + WINDOW w AS (PARTITION BY time_bucket($1::interval, c.created_at), n.name + ORDER BY c.created_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) + ) + SELECT DISTINCT ON (bucket_date, name) bucket_date, name, + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_bytes_sent - first_bytes_sent) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "bytesSentPerSecond", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_bytes_recv - first_bytes_recv) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaBytesRecv", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_packets_sent - first_packets_sent) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaPacketsSent", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_packets_recv - first_packets_recv) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaPacketsRecv", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_err_in - first_err_in) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaErrIn", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_err_out - first_err_out) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaErrOut", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_drop_in - first_drop_in) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaDropIn", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_drop_out - first_drop_out) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaDropOut", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_fifo_in - first_fifo_in) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaFifoIn", + CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 + THEN (last_fifo_out - first_fifo_out) / EXTRACT(EPOCH FROM (last_time - first_time)) + ELSE 0 END AS "deltaFifoOut" + FROM bounded + ORDER BY bucket_date, name`, + [bucket, monitorId, startDate, endDate] + ); + + const [totalResult, upResult, metricsResult, tempResult, diskResult, netResult] = await Promise.all([ + this.pool.query( + `SELECT COUNT(*)::int AS count FROM checks + WHERE monitor_id = $1 AND monitor_type = 'hardware' AND created_at >= $2 AND created_at <= $3`, + [monitorId, startDate, endDate] + ), + this.pool.query( + `SELECT COUNT(*)::int AS count FROM checks + WHERE monitor_id = $1 AND monitor_type = 'hardware' AND created_at >= $2 AND created_at <= $3 AND status = TRUE`, + [monitorId, startDate, endDate] + ), + metricsQuery, + tempQuery, + diskQuery, + netQuery, + ]); + + // Group disk and net results by bucket date + const disksByBucket = new Map(); + for (const row of diskResult.rows) { + const key = (row.bucket_date as Date).toISOString(); + if (!disksByBucket.has(key)) disksByBucket.set(key, []); + disksByBucket.get(key)!.push(row); + } + + const netsByBucket = new Map(); + for (const row of netResult.rows) { + const key = (row.bucket_date as Date).toISOString(); + if (!netsByBucket.has(key)) netsByBucket.set(key, []); + netsByBucket.get(key)!.push(row); + } + + const tempByBucket = new Map(); + for (const row of tempResult.rows) { + const key = (row.bucket_date as Date).toISOString(); + tempByBucket.set(key, Array.isArray(row.avg_temperature) ? row.avg_temperature.map(Number) : []); + } + + // Assemble checks array from metrics + disk/net maps + const checks = metricsResult.rows.map((row) => { + const bucketDate = (row.bucket_date as Date).toISOString(); + const disks = disksByBucket.get(bucketDate) ?? []; + const nets = netsByBucket.get(bucketDate) ?? []; + + return { + bucketDate, + avgCpuUsage: Number(row.avg_cpu ?? 0), + avgMemoryUsage: Number(row.avg_memory ?? 0), + avgTemperature: tempByBucket.get(bucketDate) ?? [], + disks: disks.map((d) => ({ + name: d.name ?? "", + readSpeed: Number(d.readSpeed ?? 0), + writeSpeed: Number(d.writeSpeed ?? 0), + totalBytes: Number(d.totalBytes ?? 0), + freeBytes: Number(d.freeBytes ?? 0), + usagePercent: Number(d.usagePercent ?? 0), + })), + net: nets.map((n) => ({ + name: n.name ?? "", + bytesSentPerSecond: Number(n.bytesSentPerSecond ?? 0), + deltaBytesRecv: Number(n.deltaBytesRecv ?? 0), + deltaPacketsSent: Number(n.deltaPacketsSent ?? 0), + deltaPacketsRecv: Number(n.deltaPacketsRecv ?? 0), + deltaErrIn: Number(n.deltaErrIn ?? 0), + deltaErrOut: Number(n.deltaErrOut ?? 0), + deltaDropIn: Number(n.deltaDropIn ?? 0), + deltaDropOut: Number(n.deltaDropOut ?? 0), + deltaFifoIn: Number(n.deltaFifoIn ?? 0), + deltaFifoOut: Number(n.deltaFifoOut ?? 0), + })), + }; + }); + + return { + monitorType: "hardware" as const, + aggregateData: { totalChecks: totalResult.rows[0].count }, + upChecks: { totalChecks: upResult.rows[0].count }, + checks, + }; + }; + + private findPageSpeedDateRangeChecks = async ( + monitorId: string, + startDate: Date, + endDate: Date, + dateString: string + ): Promise => { + const bucket = dateStringToBucket(dateString); + const caTable = this.getCaTable(bucket, "pagespeed"); + + const result = caTable + ? await this.pool.query( + `SELECT bucket AS bucket_date, + avg_performance AS performance, + avg_accessibility AS accessibility, + avg_best_practices AS "bestPractices", + avg_seo AS seo, + sample_count::int AS "totalChecks" + FROM ${caTable} + WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 + ORDER BY bucket`, + [monitorId, startDate, endDate] + ) + : await this.pool.query( + `SELECT + time_bucket($1::interval, created_at) AS bucket_date, + AVG(lighthouse_performance) AS performance, + AVG(lighthouse_accessibility) AS accessibility, + AVG(lighthouse_best_practices) AS "bestPractices", + AVG(lighthouse_seo) AS seo, + COUNT(*)::int AS "totalChecks" + FROM checks + WHERE monitor_id = $2 AND monitor_type = 'pagespeed' AND created_at >= $3 AND created_at <= $4 + GROUP BY bucket_date ORDER BY bucket_date`, + [bucket, monitorId, startDate, endDate] + ); + + return { + monitorType: "pagespeed" as const, + groupedChecks: result.rows.map((row) => ({ + bucketDate: (row.bucket_date as Date).toISOString(), + performance: Number(row.performance ?? 0), + accessibility: Number(row.accessibility ?? 0), + bestPractices: Number(row.bestPractices ?? 0), + seo: Number(row.seo ?? 0), + totalChecks: Number(row.totalChecks ?? 0), + })), + }; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private toEntity = (row: any, disk?: CheckDiskInfo[], errors?: CheckErrorInfo[], net?: CheckNetworkInterfaceInfo[]): Check => { + const timings: GotTimings = { + start: row.timing_start ?? 0, + socket: row.timing_socket ?? 0, + lookup: row.timing_lookup ?? 0, + connect: row.timing_connect ?? 0, + secureConnect: row.timing_secure_connect ?? 0, + upload: row.timing_upload ?? 0, + response: row.timing_response ?? 0, + end: row.timing_end ?? 0, + phases: { + wait: row.phase_wait ?? 0, + dns: row.phase_dns ?? 0, + tcp: row.phase_tcp ?? 0, + tls: row.phase_tls ?? 0, + request: row.phase_request ?? 0, + firstByte: row.phase_first_byte ?? 0, + download: row.phase_download ?? 0, + total: row.phase_total ?? 0, + }, + }; + + const cpu: CheckCpuInfo = { + physical_core: row.cpu_physical_core ?? 0, + logical_core: row.cpu_logical_core ?? 0, + frequency: row.cpu_frequency ?? 0, + current_frequency: row.cpu_current_frequency ?? 0, + temperature: row.cpu_temperature ?? [], + free_percent: row.cpu_free_percent ?? 0, + usage_percent: row.cpu_usage_percent ?? 0, + }; + + const memory: CheckMemoryInfo = { + total_bytes: Number(row.mem_total_bytes ?? 0), + available_bytes: Number(row.mem_available_bytes ?? 0), + used_bytes: Number(row.mem_used_bytes ?? 0), + usage_percent: row.mem_usage_percent ?? 0, + }; + + const host: CheckHostInfo = { + os: row.host_os ?? "", + platform: row.host_platform ?? "", + kernel_version: row.host_kernel_version ?? "", + pretty_name: row.host_pretty_name ?? "", + }; + + const capture: CheckCaptureInfo = { + version: row.capture_version ?? "", + mode: row.capture_mode ?? "", + }; + + const audits: CheckAudits | undefined = + row.audit_cls_score !== null + ? { + cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, + si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, + fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, + lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, + tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, + } + : undefined; + + return { + id: row.id, + metadata: { + monitorId: row.monitor_id, + teamId: row.team_id, + type: row.monitor_type, + }, + status: row.status ?? false, + responseTime: row.response_time ?? 0, + timings, + statusCode: row.status_code ?? 0, + message: row.message ?? "", + cpu, + memory, + disk: disk ?? [], + host, + errors: errors ?? [], + capture, + net: net ?? [], + accessibility: row.lighthouse_accessibility ?? undefined, + bestPractices: row.lighthouse_best_practices ?? undefined, + seo: row.lighthouse_seo ?? undefined, + performance: row.lighthouse_performance ?? undefined, + audits, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; + }; +} diff --git a/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts b/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts new file mode 100644 index 000000000..e4528f259 --- /dev/null +++ b/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts @@ -0,0 +1,274 @@ +import type { Pool } from "pg"; +import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js"; +import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js"; +import { getDateForRange } from "@/utils/dataUtils.js"; + +const dateStringToBucket = (dateString: string): string => { + if (dateString.includes("%M")) return "1 minute"; + if (dateString.includes("%H")) return "1 hour"; + return "1 day"; +}; + +export class TimescaleGeoChecksRepository implements IGeoChecksRepository { + constructor(private pool: Pool) {} + + createGeoChecks = async (geoChecks: Omit[]): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]); + return result.rowCount ?? 0; + }; + + deleteByTeamId = async (teamId: string): Promise => { + const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]); + return result.rowCount ?? 0; + }; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { + 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): 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, 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(), + }); +} diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentsRepository.ts similarity index 98% rename from server/src/repositories/incidents/MongoIncidentRepository.ts rename to server/src/repositories/incidents/MongoIncidentsRepository.ts index 096ba3d37..e24551462 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentsRepository.ts @@ -5,7 +5,7 @@ import type { IIncidentsRepository } from "@/repositories/index.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; -class MongoIncidentRepository implements IIncidentsRepository { +class MongoIncidentsRepository implements IIncidentsRepository { private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { if (!value) { return ""; @@ -288,4 +288,4 @@ class MongoIncidentRepository implements IIncidentsRepository { return result.deletedCount ?? 0; }; } -export default MongoIncidentRepository; +export default MongoIncidentsRepository; diff --git a/server/src/repositories/incidents/TimescaleIncidentsRepository.ts b/server/src/repositories/incidents/TimescaleIncidentsRepository.ts new file mode 100644 index 000000000..8a6ad7d22 --- /dev/null +++ b/server/src/repositories/incidents/TimescaleIncidentsRepository.ts @@ -0,0 +1,294 @@ +import type { Pool } from "pg"; +import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js"; +import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js"; +import { AppError } from "@/utils/AppError.js"; + +interface IncidentRow { + id: string; + monitor_id: string; + team_id: string; + start_time: Date; + end_time: Date | null; + status: boolean; + message: string | null; + status_code: number | null; + resolution_type: IncidentResolutionType; + resolved_by: string | null; + resolved_by_email: string | null; + comment: string | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code, + resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`; + +export class TimescaleIncidentsRepository implements IIncidentsRepository { + constructor(private pool: Pool) {} + + create = async (incident: Partial): Promise => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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 => { + const result = await this.pool.query(`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 => { + const result = await this.pool.query(`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 => { + 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( + `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 => { + 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 => { + // 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): Promise => { + 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( + `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 => { + 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 => { + 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(), + }); +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 2dc1a17c3..84a9a7cda 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,41 +1,54 @@ export * from "@/repositories/monitors/IMonitorsRepository.js"; export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js"; +export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js"; export * from "@/repositories/checks/IChecksRepository.js"; export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js"; +export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js"; export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js"; export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js"; +export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js"; export * from "@/repositories/status-pages/IStatusPagesRepository.js"; export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; +export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js"; export * from "@/repositories/users/IUsersRepository.js"; export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js"; +export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js"; export * from "@/repositories/invites/IInvitesRepository.js"; export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; +export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js"; export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; +export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js"; export * from "@/repositories/settings/ISettingsRepository.js"; export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js"; +export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js"; export * from "@/repositories/notifications/INotificationsRepository.js"; export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; +export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js"; export * from "@/repositories/incidents/IIncidentsRepository.js"; -export { default as MongoIncidentRepository } from "@/repositories/incidents/MongoIncidentRepository.js"; +export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; +export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js"; export * from "@/repositories/teams/ITeamsRepository.js"; export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js"; +export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js"; export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js"; export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js"; +export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js"; export * from "@/repositories/geo-checks/IGeoChecksRepository.js"; export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js"; export * from "@/repositories/dlq/IDLQRepository.js"; export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js"; +export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js"; diff --git a/server/src/repositories/invites/TimescaleInvitesRepository.ts b/server/src/repositories/invites/TimescaleInvitesRepository.ts new file mode 100644 index 000000000..853047745 --- /dev/null +++ b/server/src/repositories/invites/TimescaleInvitesRepository.ts @@ -0,0 +1,77 @@ +import type { Pool } from "pg"; +import crypto from "crypto"; +import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js"; +import type { Invite } from "@/types/invite.js"; +import type { UserRole } from "@/types/user.js"; +import { AppError } from "@/utils/AppError.js"; + +const parsePostgresArray = (value: unknown): UserRole[] => { + if (typeof value !== "string") return []; + const inner = value.replace(/^\{|\}$/g, ""); + if (inner === "") return []; + return inner.split(",") as UserRole[]; +}; + +interface InviteRow { + id: string; + email: string; + team_id: string; + roles: UserRole[]; + token: string; + expiry: Date | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`; + +export class TimescaleInvitesRepository implements IInvitesRepository { + constructor(private pool: Pool) {} + + create = async (invite: Partial): Promise => { + // 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( + `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 => { + const result = await this.pool.query(`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 => { + const result = await this.pool.query(`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(), + }); +} diff --git a/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts new file mode 100644 index 000000000..cfe28198a --- /dev/null +++ b/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts @@ -0,0 +1,197 @@ +import type { Pool } from "pg"; +import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js"; +import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js"; +import { AppError } from "@/utils/AppError.js"; + +interface MaintenanceWindowRow { + id: string; + monitor_id: string; + team_id: string; + active: boolean; + name: string; + duration: number; + duration_unit: DurationUnit; + repeat: number; + start_time: Date; + end_time: Date; + expiry: Date | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat, + start_time, end_time, expiry, created_at, updated_at`; + +export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository { + constructor(private pool: Pool) {} + + create = async (data: Partial): Promise => { + 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( + `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 => { + const result = await this.pool.query(`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 => { + const result = await this.pool.query(`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 => { + 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 = { + 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( + `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): Promise => { + 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( + `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 => { + const result = await this.pool.query( + `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 => { + 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(), + }); +} diff --git a/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts new file mode 100644 index 000000000..f5801a1c0 --- /dev/null +++ b/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts @@ -0,0 +1,140 @@ +import type { Pool } from "pg"; +import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js"; +import type { MonitorStats } from "@/types/monitorStats.js"; +import { AppError } from "@/utils/AppError.js"; + +interface MonitorStatsRow { + id: string; + monitor_id: string; + avg_response_time: number; + max_response_time: number; + total_checks: number; + total_up_checks: number; + total_down_checks: number; + uptime_percentage: number; + last_check_timestamp: Date | null; + last_response_time: number; + time_of_last_failure: Date | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks, + total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure, + created_at, updated_at`; + +export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository { + constructor(private pool: Pool) {} + + create = async (data: Omit): Promise => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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): Promise => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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 => { + 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 => { + 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(), + }); +} diff --git a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts new file mode 100644 index 000000000..a94d38504 --- /dev/null +++ b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts @@ -0,0 +1,1044 @@ +import type { Pool } from "pg"; +import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent } from "@/types/monitor.js"; +import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; +import { AppError } from "@/utils/AppError.js"; + +interface MonitorRow { + id: string; + user_id: string; + team_id: string; + name: string; + description: string | null; + type: MonitorType; + status: MonitorStatus; + url: string | null; + port: number | null; + ignore_tls_errors: boolean; + use_advanced_matching: boolean; + json_path: string | null; + expected_value: string | null; + match_method: MonitorMatchMethod | null; + secret: string | null; + interval_ms: number; + is_active: boolean; + status_window: boolean[] | null; + status_window_size: number; + status_window_threshold: number; + uptime_percentage: number | null; + cpu_alert_threshold: number; + cpu_alert_counter: number; + memory_alert_threshold: number; + memory_alert_counter: number; + disk_alert_threshold: number; + disk_alert_counter: number; + temp_alert_threshold: number; + temp_alert_counter: number; + selected_disks: string[] | null; + game_id: string | null; + grpc_service_name: string | null; + monitor_group: string | null; + geo_check_enabled: boolean; + geo_check_locations: GeoContinent[] | null; + geo_check_interval_ms: number; + created_at: Date; + updated_at: Date; +} + +const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status, url, port, + ignore_tls_errors, use_advanced_matching, json_path, expected_value, match_method, secret, + interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, + cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, + disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, + game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, + created_at, updated_at`; + +export class TimescaleMonitorsRepository implements IMonitorsRepository { + constructor(private pool: Pool) {} + + create = async (monitor: Monitor, teamId: string, userId: string): Promise => { + const result = await this.pool.query( + `INSERT INTO monitors (user_id, team_id, name, description, type, status, url, port, + ignore_tls_errors, use_advanced_matching, json_path, expected_value, match_method, secret, + interval_ms, is_active, status_window, status_window_size, status_window_threshold, + cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, + disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, + game_id, grpc_service_name, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34) + RETURNING ${MONITOR_COLUMNS}`, + [ + userId, + teamId, + monitor.name, + monitor.description ?? null, + monitor.type, + monitor.status ?? "initializing", + monitor.url ?? null, + monitor.port ?? null, + monitor.ignoreTlsErrors ?? false, + monitor.useAdvancedMatching ?? false, + monitor.jsonPath ?? null, + monitor.expectedValue ?? null, + monitor.matchMethod || null, + monitor.secret ?? null, + monitor.interval ?? 60000, + monitor.isActive ?? true, + monitor.statusWindow ?? null, + monitor.statusWindowSize ?? 5, + monitor.statusWindowThreshold ?? 60, + monitor.cpuAlertThreshold ?? 0, + monitor.cpuAlertCounter ?? 0, + monitor.memoryAlertThreshold ?? 0, + monitor.memoryAlertCounter ?? 0, + monitor.diskAlertThreshold ?? 0, + monitor.diskAlertCounter ?? 0, + monitor.tempAlertThreshold ?? 0, + monitor.tempAlertCounter ?? 0, + monitor.selectedDisks ?? [], + monitor.gameId ?? null, + monitor.grpcServiceName ?? null, + monitor.group ?? null, + monitor.geoCheckEnabled ?? false, + monitor.geoCheckLocations ?? [], + monitor.geoCheckInterval ?? 300000, + ] + ); + const row = result.rows[0]; + if (!row) { + return null; + } + + // Insert notification associations + if (monitor.notifications?.length) { + for (const notificationId of monitor.notifications) { + await this.pool.query(`INSERT INTO monitor_notifications (monitor_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ + row.id, + notificationId, + ]); + } + } + + const entity = this.toEntity(row); + entity.notifications = monitor.notifications ?? []; + return entity; + }; + + createMonitors = async (monitors: Monitor[]): Promise => { + if (!monitors.length) { + return []; + } + const created: Monitor[] = []; + for (const monitor of monitors) { + const result = await this.create(monitor, monitor.teamId, monitor.userId); + if (result) { + created.push(result); + } + } + return created; + }; + + findById = async (monitorId: string, teamId: string): Promise => { + const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE id = $1 AND team_id = $2`, [monitorId, teamId]); + const row = result.rows[0]; + if (!row) { + throw new AppError({ message: `Monitor with ID ${monitorId} not found`, status: 404 }); + } + const monitor = this.toEntity(row); + + // Populate latest check with child data + const checkResult = await this.pool.query(`SELECT * FROM checks WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1`, [monitorId]); + if (checkResult.rows[0]) { + const checkRow = checkResult.rows[0]; + const childData = await this.fetchCheckChildData(checkRow.id); + monitor.recentChecks = [this.toCheckSnapshot(checkRow, childData)]; + } + + // Populate notifications + monitor.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); + + return monitor; + }; + + findAll = async (): Promise => { + const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors`); + const monitors = result.rows.map(this.toEntity); + if (monitors.length > 0) { + const notifMap = await this.fetchNotificationIds(monitors.map((m) => m.id)); + for (const monitor of monitors) { + monitor.notifications = notifMap.get(monitor.id) ?? []; + } + } + return monitors; + }; + + findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { + const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {}; + + const conditions: string[] = ["team_id = $1"]; + const values: unknown[] = [teamId]; + let paramIndex = 2; + + if (type !== undefined) { + if (Array.isArray(type)) { + conditions.push(`type = ANY($${paramIndex++})`); + values.push(type); + } else { + conditions.push(`type = $${paramIndex++}`); + values.push(type); + } + } + + if (filter !== undefined) { + switch (field) { + case "name": + conditions.push(`(name ILIKE $${paramIndex} OR url ILIKE $${paramIndex})`); + values.push(`%${filter}%`); + paramIndex++; + break; + case "isActive": + conditions.push(`is_active = $${paramIndex++}`); + values.push(filter === "true"); + break; + case "status": + conditions.push(`status = $${paramIndex++}`); + values.push(filter); + break; + case "type": + conditions.push(`type = $${paramIndex++}`); + values.push(filter); + break; + default: + break; + } + } + + const fieldMap: Record = { + createdAt: "created_at", + name: "name", + status: "status", + type: "type", + isActive: "is_active", + }; + const sortColumn = fieldMap[field] ?? "created_at"; + const sortDirection = order === "asc" ? "ASC" : "DESC"; + + let query = `SELECT ${MONITOR_COLUMNS} FROM monitors WHERE ${conditions.join(" AND ")} ORDER BY ${sortColumn} ${sortDirection}`; + + if (rowsPerPage > 0) { + const offset = Math.max(page, 0) * rowsPerPage; + query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; + values.push(rowsPerPage, offset); + } + + const result = await this.pool.query(query, values); + const monitors = result.rows.map(this.toEntity); + + if (monitors.length === 0) return monitors; + + // Populate recentChecks — 25 latest checks per monitor in a single query + const monitorIds = monitors.map((m) => m.id); + const checksResult = await this.pool.query( + `SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY created_at DESC) AS rn + FROM checks + WHERE monitor_id = ANY($1) + ) sub WHERE rn <= 25 + ORDER BY monitor_id, created_at ASC`, + [monitorIds] + ); + + // Group checks by monitor_id + const checksMap = new Map(); + for (const row of checksResult.rows) { + if (!checksMap.has(row.monitor_id)) checksMap.set(row.monitor_id, []); + checksMap.get(row.monitor_id)!.push(row); + } + + // Batch fetch child data for all checks in 3 queries + const checkIds = checksResult.rows.map((r) => r.id); + const diskMap = new Map[]>(); + const netMap = new Map[]>(); + const errorsMap = new Map[]>(); + + if (checkIds.length > 0) { + const [disksResult, netsResult, errsResult] = await Promise.all([ + this.pool.query( + `SELECT check_id, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, + total_inodes, free_inodes, used_inodes, inodes_usage_percent, + read_bytes, write_bytes, read_time, write_time + FROM check_disks WHERE check_id = ANY($1)`, + [checkIds] + ), + this.pool.query( + `SELECT check_id, name, bytes_sent, bytes_recv, packets_sent, packets_recv, + err_in, err_out, drop_in, drop_out, fifo_in, fifo_out + FROM check_network_interfaces WHERE check_id = ANY($1)`, + [checkIds] + ), + this.pool.query( + `SELECT check_id, metrics, error + FROM check_errors WHERE check_id = ANY($1)`, + [checkIds] + ), + ]); + + for (const d of disksResult.rows) { + const key = d.check_id as string; + if (!diskMap.has(key)) diskMap.set(key, []); + diskMap.get(key)!.push(d); + } + for (const n of netsResult.rows) { + const key = n.check_id as string; + if (!netMap.has(key)) netMap.set(key, []); + netMap.get(key)!.push(n); + } + for (const e of errsResult.rows) { + const key = e.check_id as string; + if (!errorsMap.has(key)) errorsMap.set(key, []); + errorsMap.get(key)!.push(e); + } + } + + for (const monitor of monitors) { + const rows = checksMap.get(monitor.id) ?? []; + monitor.recentChecks = rows.map((row) => { + const childData = { + disk: (diskMap.get(row.id) ?? []).map((d) => ({ + device: d.device, + mountpoint: d.mountpoint, + total_bytes: Number(d.total_bytes), + free_bytes: Number(d.free_bytes), + used_bytes: Number(d.used_bytes), + usage_percent: d.usage_percent as number, + total_inodes: Number(d.total_inodes), + free_inodes: Number(d.free_inodes), + used_inodes: Number(d.used_inodes), + inodes_usage_percent: d.inodes_usage_percent as number, + read_bytes: Number(d.read_bytes), + write_bytes: Number(d.write_bytes), + read_time: Number(d.read_time), + write_time: Number(d.write_time), + })), + net: (netMap.get(row.id) ?? []).map((n) => ({ + name: n.name as string, + bytes_sent: Number(n.bytes_sent), + bytes_recv: Number(n.bytes_recv), + packets_sent: Number(n.packets_sent), + packets_recv: Number(n.packets_recv), + err_in: Number(n.err_in), + err_out: Number(n.err_out), + drop_in: Number(n.drop_in), + drop_out: Number(n.drop_out), + fifo_in: Number(n.fifo_in), + fifo_out: Number(n.fifo_out), + })), + errors: (errorsMap.get(row.id) ?? []).map((e) => ({ + metric: (e.metrics as string[]) ?? [], + err: (e.error as string) ?? "", + })), + }; + return this.toCheckSnapshot(row, childData); + }); + } + + // Populate notifications in batch + const notifMap = await this.fetchNotificationIds(monitorIds); + for (const monitor of monitors) { + monitor.notifications = notifMap.get(monitor.id) ?? []; + } + + return monitors; + }; + + findByIds = async (monitorIds: string[]): Promise => { + if (!monitorIds.length) { + return []; + } + const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE id = ANY($1)`, [monitorIds]); + const monitors = result.rows.map(this.toEntity); + const notifMap = await this.fetchNotificationIds(monitorIds); + for (const monitor of monitors) { + monitor.notifications = notifMap.get(monitor.id) ?? []; + } + return monitors; + }; + + findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { + if (!monitorIds.length) { + return []; + } + const monitors = await this.findByIds(monitorIds); + + for (const monitor of monitors) { + const checksResult = await this.pool.query( + `SELECT * FROM checks + WHERE monitor_id = $1 + ORDER BY created_at DESC + LIMIT $2`, + [monitor.id, checksCount] + ); + monitor.recentChecks = await Promise.all( + checksResult.rows.map(async (row) => { + // Fetch child records for hardware monitors + let disk: import("@/types/index.js").CheckDiskInfo[] = []; + let net: import("@/types/index.js").CheckNetworkInterfaceInfo[] = []; + let errors: import("@/types/index.js").CheckErrorInfo[] = []; + + if (monitor.type === "hardware") { + const [diskResult, netResult, errorsResult] = await Promise.all([ + this.pool.query( + `SELECT device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, + total_inodes, free_inodes, used_inodes, inodes_usage_percent, + read_bytes, write_bytes, read_time, write_time + FROM check_disks WHERE check_id = $1`, + [row.id] + ), + this.pool.query( + `SELECT name, bytes_sent, bytes_recv, packets_sent, packets_recv, + err_in, err_out, drop_in, drop_out, fifo_in, fifo_out + FROM check_network_interfaces WHERE check_id = $1`, + [row.id] + ), + this.pool.query(`SELECT metrics, error FROM check_errors WHERE check_id = $1`, [row.id]), + ]); + disk = diskResult.rows.map((d) => ({ + device: d.device, + mountpoint: d.mountpoint, + total_bytes: Number(d.total_bytes), + free_bytes: Number(d.free_bytes), + used_bytes: Number(d.used_bytes), + usage_percent: d.usage_percent, + total_inodes: Number(d.total_inodes), + free_inodes: Number(d.free_inodes), + used_inodes: Number(d.used_inodes), + inodes_usage_percent: d.inodes_usage_percent, + read_bytes: Number(d.read_bytes), + write_bytes: Number(d.write_bytes), + read_time: Number(d.read_time), + write_time: Number(d.write_time), + })); + net = netResult.rows.map((n) => ({ + name: n.name, + bytes_sent: Number(n.bytes_sent), + bytes_recv: Number(n.bytes_recv), + packets_sent: Number(n.packets_sent), + packets_recv: Number(n.packets_recv), + err_in: Number(n.err_in), + err_out: Number(n.err_out), + drop_in: Number(n.drop_in), + drop_out: Number(n.drop_out), + fifo_in: Number(n.fifo_in), + fifo_out: Number(n.fifo_out), + })); + errors = errorsResult.rows.map((e) => ({ metric: e.metrics ?? [], err: e.error ?? "" })); + } + + return { + id: row.id, + status: row.status, + responseTime: row.response_time ?? 0, + statusCode: row.status_code ?? 0, + message: row.message ?? "", + timings: + row.timing_start !== null + ? { + start: row.timing_start, + socket: row.timing_socket, + lookup: row.timing_lookup, + connect: row.timing_connect, + secureConnect: row.timing_secure_connect, + upload: row.timing_upload, + response: row.timing_response, + end: row.timing_end, + phases: { + wait: row.phase_wait, + dns: row.phase_dns, + tcp: row.phase_tcp, + tls: row.phase_tls, + request: row.phase_request, + firstByte: row.phase_first_byte, + download: row.phase_download, + total: row.phase_total, + }, + } + : undefined, + cpu: + row.cpu_usage_percent !== null + ? { + physical_core: row.cpu_physical_core, + logical_core: row.cpu_logical_core, + frequency: row.cpu_frequency, + current_frequency: row.cpu_current_frequency, + temperature: row.cpu_temperature ?? [], + free_percent: row.cpu_free_percent, + usage_percent: row.cpu_usage_percent, + } + : undefined, + memory: + row.mem_usage_percent !== null + ? { + total_bytes: Number(row.mem_total_bytes), + available_bytes: Number(row.mem_available_bytes), + used_bytes: Number(row.mem_used_bytes), + usage_percent: row.mem_usage_percent, + } + : undefined, + disk, + host: + row.host_os !== null + ? { + os: row.host_os, + platform: row.host_platform, + kernel_version: row.host_kernel_version, + pretty_name: row.host_pretty_name, + } + : undefined, + errors, + capture: + row.capture_version !== null + ? { + version: row.capture_version, + mode: row.capture_mode, + } + : undefined, + net, + performance: row.lighthouse_performance ?? undefined, + accessibility: row.lighthouse_accessibility ?? undefined, + bestPractices: row.lighthouse_best_practices ?? undefined, + seo: row.lighthouse_seo ?? undefined, + audits: + row.audit_cls_score !== null + ? { + cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, + si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, + fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, + lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, + tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, + } + : undefined, + createdAt: row.created_at.toISOString(), + }; + }) + ); + + const maintResult = await this.pool.query( + `SELECT 1 FROM maintenance_windows + WHERE monitor_id = $1 AND active = TRUE AND start_time <= NOW() AND end_time >= NOW() + LIMIT 1`, + [monitor.id] + ); + if ((maintResult.rowCount ?? 0) > 0) { + monitor.status = "maintenance"; + } + + const statsResult = await this.pool.query(`SELECT uptime_percentage FROM monitor_stats WHERE monitor_id = $1`, [monitor.id]); + if (statsResult.rows[0]) { + monitor.uptimePercentage = statsResult.rows[0].uptime_percentage; + } + } + + return monitors; + }; + + findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise => { + const { type } = config ?? {}; + const conditions: string[] = ["team_id = $1"]; + const values: unknown[] = [teamId]; + let paramIndex = 2; + + if (type !== undefined) { + if (Array.isArray(type)) { + conditions.push(`type = ANY($${paramIndex++})`); + values.push(type); + } else { + conditions.push(`type = $${paramIndex++}`); + values.push(type); + } + } + + const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM monitors WHERE ${conditions.join(" AND ")}`, values); + return result.rows[0].count; + }; + + updateById = async (monitorId: string, teamId: string, patch: Partial): Promise => { + const sets: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + const fieldMap: [keyof Monitor, string][] = [ + ["name", "name"], + ["description", "description"], + ["type", "type"], + ["status", "status"], + ["url", "url"], + ["port", "port"], + ["ignoreTlsErrors", "ignore_tls_errors"], + ["useAdvancedMatching", "use_advanced_matching"], + ["jsonPath", "json_path"], + ["expectedValue", "expected_value"], + ["matchMethod", "match_method"], + ["secret", "secret"], + ["interval", "interval_ms"], + ["isActive", "is_active"], + ["statusWindow", "status_window"], + ["statusWindowSize", "status_window_size"], + ["statusWindowThreshold", "status_window_threshold"], + ["uptimePercentage", "uptime_percentage"], + ["cpuAlertThreshold", "cpu_alert_threshold"], + ["cpuAlertCounter", "cpu_alert_counter"], + ["memoryAlertThreshold", "memory_alert_threshold"], + ["memoryAlertCounter", "memory_alert_counter"], + ["diskAlertThreshold", "disk_alert_threshold"], + ["diskAlertCounter", "disk_alert_counter"], + ["tempAlertThreshold", "temp_alert_threshold"], + ["tempAlertCounter", "temp_alert_counter"], + ["selectedDisks", "selected_disks"], + ["gameId", "game_id"], + ["grpcServiceName", "grpc_service_name"], + ["group", "monitor_group"], + ["geoCheckEnabled", "geo_check_enabled"], + ["geoCheckLocations", "geo_check_locations"], + ["geoCheckInterval", "geo_check_interval_ms"], + ]; + + for (const [key, column] of fieldMap) { + if (patch[key] !== undefined) { + sets.push(`${column} = $${paramIndex++}`); + // Empty string matchMethod is stored as NULL (not a valid enum value) + values.push(key === "matchMethod" && patch[key] === "" ? null : patch[key]); + } + } + + if (sets.length === 0) { + return this.findById(monitorId, teamId); + } + + sets.push(`updated_at = NOW()`); + values.push(monitorId, teamId); + + const result = await this.pool.query( + `UPDATE monitors SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} + RETURNING ${MONITOR_COLUMNS}`, + values + ); + const row = result.rows[0]; + if (!row) { + throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 }); + } + + // Update notification associations if provided + if (patch.notifications !== undefined) { + await this.pool.query(`DELETE FROM monitor_notifications WHERE monitor_id = $1`, [monitorId]); + for (const notificationId of patch.notifications) { + await this.pool.query(`INSERT INTO monitor_notifications (monitor_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ + monitorId, + notificationId, + ]); + } + } + + const entity = this.toEntity(row); + entity.notifications = patch.notifications ?? (await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? [])); + return entity; + }; + + togglePauseById = async (monitorId: string, teamId: string): Promise => { + const result = await this.pool.query( + `UPDATE monitors SET + is_active = NOT is_active, + status = CASE WHEN status = 'paused' THEN 'initializing'::monitor_status ELSE 'paused'::monitor_status END, + updated_at = NOW() + WHERE id = $1 AND team_id = $2 + RETURNING ${MONITOR_COLUMNS}`, + [monitorId, teamId] + ); + const row = result.rows[0]; + if (!row) { + throw new AppError({ message: `Monitor with ID ${monitorId} not found for the given team.`, status: 404 }); + } + const entity = this.toEntity(row); + entity.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); + return entity; + }; + + deleteById = async (monitorId: string, teamId: string): Promise => { + // Fetch notifications before delete (FK cascade will remove join rows) + const notifs = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); + const result = await this.pool.query(`DELETE FROM monitors WHERE id = $1 AND team_id = $2 RETURNING ${MONITOR_COLUMNS}`, [ + monitorId, + teamId, + ]); + const row = result.rows[0]; + if (!row) { + throw new AppError({ message: `Monitor with ID ${monitorId} not found for the given team.`, status: 404 }); + } + const entity = this.toEntity(row); + entity.notifications = notifs; + return entity; + }; + + deleteByTeamId = async (teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> => { + // Fetch notifications before delete + const monitorsResult = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE team_id = $1`, [teamId]); + const monitorIds = monitorsResult.rows.map((r) => r.id); + const notifMap = monitorIds.length > 0 ? await this.fetchNotificationIds(monitorIds) : new Map(); + + const result = await this.pool.query(`DELETE FROM monitors WHERE team_id = $1 RETURNING ${MONITOR_COLUMNS}`, [teamId]); + const monitors = result.rows.map((row) => { + const entity = this.toEntity(row); + entity.notifications = notifMap.get(row.id) ?? []; + return entity; + }); + return { monitors, deletedCount: result.rowCount ?? 0 }; + }; + + findMonitorsSummaryByTeamId = async (teamId: string, config?: SummaryConfig): Promise => { + const conditions: string[] = ["team_id = $1"]; + const values: unknown[] = [teamId]; + let paramIndex = 2; + + if (config?.type !== undefined) { + if (Array.isArray(config.type)) { + conditions.push(`type = ANY($${paramIndex++})`); + values.push(config.type); + } else { + conditions.push(`type = $${paramIndex++}`); + values.push(config.type); + } + } + + const result = await this.pool.query( + `SELECT + COUNT(*)::int AS "totalMonitors", + COUNT(*) FILTER (WHERE status = 'up')::int AS "upMonitors", + COUNT(*) FILTER (WHERE status = 'down')::int AS "downMonitors", + COUNT(*) FILTER (WHERE status = 'paused')::int AS "pausedMonitors", + COUNT(*) FILTER (WHERE status = 'initializing')::int AS "initializingMonitors", + COUNT(*) FILTER (WHERE status = 'maintenance')::int AS "maintenanceMonitors", + COUNT(*) FILTER (WHERE status = 'breached')::int AS "breachedMonitors" + FROM monitors WHERE ${conditions.join(" AND ")}`, + values + ); + + return ( + result.rows[0] ?? { + totalMonitors: 0, + upMonitors: 0, + downMonitors: 0, + pausedMonitors: 0, + initializingMonitors: 0, + maintenanceMonitors: 0, + breachedMonitors: 0, + } + ); + }; + + findGroupsByTeamId = async (teamId: string): Promise => { + const result = await this.pool.query( + `SELECT DISTINCT monitor_group FROM monitors + WHERE team_id = $1 AND monitor_group IS NOT NULL AND monitor_group != '' + ORDER BY monitor_group`, + [teamId] + ); + return result.rows.map((row) => row.monitor_group); + }; + + removeNotificationFromMonitors = async (notificationId: string): Promise => { + await this.pool.query(`DELETE FROM monitor_notifications WHERE notification_id = $1`, [notificationId]); + }; + + updateNotifications = async ( + teamId: string, + monitorIds: string[], + notificationIds: string[], + action: "add" | "remove" | "set" + ): Promise => { + if (!monitorIds.length) { + return 0; + } + + // Verify monitors belong to team + const monitorCheck = await this.pool.query(`SELECT id FROM monitors WHERE id = ANY($1) AND team_id = $2`, [monitorIds, teamId]); + const validMonitorIds = monitorCheck.rows.map((row) => row.id); + + if (!validMonitorIds.length) { + return 0; + } + + let modified = 0; + + switch (action) { + case "set": { + // Track which monitors actually change + const existingSet = await this.pool.query(`SELECT monitor_id, notification_id FROM monitor_notifications WHERE monitor_id = ANY($1)`, [ + validMonitorIds, + ]); + const existingByMonitor = new Map>(); + for (const row of existingSet.rows) { + if (!existingByMonitor.has(row.monitor_id)) existingByMonitor.set(row.monitor_id, new Set()); + existingByMonitor.get(row.monitor_id)!.add(row.notification_id); + } + + await this.pool.query(`DELETE FROM monitor_notifications WHERE monitor_id = ANY($1)`, [validMonitorIds]); + for (const monitorId of validMonitorIds) { + const existing = existingByMonitor.get(monitorId); + const newSet = new Set(notificationIds); + const changed = !existing || existing.size !== newSet.size || [...newSet].some((id) => !existing.has(id)); + if (changed) modified++; + + for (const notificationId of notificationIds) { + await this.pool.query( + `INSERT INTO monitor_notifications (monitor_id, notification_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [monitorId, notificationId] + ); + } + } + break; + } + case "add": { + // Count monitors where at least one new notification was added + const monitorsModified = new Set(); + for (const monitorId of validMonitorIds) { + for (const notificationId of notificationIds) { + const res = await this.pool.query( + `INSERT INTO monitor_notifications (monitor_id, notification_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [monitorId, notificationId] + ); + if ((res.rowCount ?? 0) > 0) { + monitorsModified.add(monitorId); + } + } + } + modified = monitorsModified.size; + break; + } + case "remove": { + // Count distinct monitors that will be affected before deleting + const affectedResult = await this.pool.query( + `SELECT COUNT(DISTINCT monitor_id)::int AS count FROM monitor_notifications + WHERE monitor_id = ANY($1) AND notification_id = ANY($2)`, + [validMonitorIds, notificationIds] + ); + modified = affectedResult.rows[0]?.count ?? 0; + await this.pool.query( + `DELETE FROM monitor_notifications + WHERE monitor_id = ANY($1) AND notification_id = ANY($2)`, + [validMonitorIds, notificationIds] + ); + break; + } + default: + throw new AppError({ message: `Invalid action: ${action}`, status: 400 }); + } + + return modified; + }; + + deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { + if (!teamIds.length) { + const result = await this.pool.query(`DELETE FROM monitors`); + return result.rowCount ?? 0; + } + const result = await this.pool.query(`DELETE FROM monitors WHERE team_id != ALL($1)`, [teamIds]); + return result.rowCount ?? 0; + }; + + findAllMonitorIds = async (): Promise => { + const result = await this.pool.query(`SELECT id FROM monitors`); + return result.rows.map((row) => row.id); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private toCheckSnapshot = (row: any, childData?: { disk: any[]; net: any[]; errors: any[] }) => ({ + id: row.id, + status: row.status, + responseTime: row.response_time ?? 0, + statusCode: row.status_code ?? 0, + message: row.message ?? "", + timings: + row.timing_start !== null + ? { + start: row.timing_start, + socket: row.timing_socket, + lookup: row.timing_lookup, + connect: row.timing_connect, + secureConnect: row.timing_secure_connect, + upload: row.timing_upload, + response: row.timing_response, + end: row.timing_end, + phases: { + wait: row.phase_wait, + dns: row.phase_dns, + tcp: row.phase_tcp, + tls: row.phase_tls, + request: row.phase_request, + firstByte: row.phase_first_byte, + download: row.phase_download, + total: row.phase_total, + }, + } + : undefined, + cpu: + row.cpu_usage_percent !== null + ? { + physical_core: row.cpu_physical_core, + logical_core: row.cpu_logical_core, + frequency: row.cpu_frequency, + current_frequency: row.cpu_current_frequency, + temperature: row.cpu_temperature ?? [], + free_percent: row.cpu_free_percent, + usage_percent: row.cpu_usage_percent, + } + : undefined, + memory: + row.mem_usage_percent !== null + ? { + total_bytes: Number(row.mem_total_bytes), + available_bytes: Number(row.mem_available_bytes), + used_bytes: Number(row.mem_used_bytes), + usage_percent: row.mem_usage_percent, + } + : undefined, + disk: childData?.disk ?? [], + host: + row.host_os !== null + ? { + os: row.host_os, + platform: row.host_platform, + kernel_version: row.host_kernel_version, + pretty_name: row.host_pretty_name, + } + : undefined, + errors: childData?.errors ?? [], + capture: + row.capture_version !== null + ? { + version: row.capture_version, + mode: row.capture_mode, + } + : undefined, + net: childData?.net ?? [], + performance: row.lighthouse_performance ?? undefined, + accessibility: row.lighthouse_accessibility ?? undefined, + bestPractices: row.lighthouse_best_practices ?? undefined, + seo: row.lighthouse_seo ?? undefined, + audits: + row.audit_cls_score !== null + ? { + cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, + si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, + fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, + lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, + tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, + } + : undefined, + createdAt: row.created_at.toISOString(), + }); + + private fetchNotificationIds = async (monitorIds: string[]): Promise> => { + const result = await this.pool.query(`SELECT monitor_id, notification_id FROM monitor_notifications WHERE monitor_id = ANY($1)`, [monitorIds]); + const map = new Map(); + for (const row of result.rows) { + if (!map.has(row.monitor_id)) map.set(row.monitor_id, []); + map.get(row.monitor_id)!.push(row.notification_id); + } + return map; + }; + + private fetchCheckChildData = async (checkId: string) => { + const [diskResult, netResult, errorsResult] = await Promise.all([ + this.pool.query( + `SELECT device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, + total_inodes, free_inodes, used_inodes, inodes_usage_percent, + read_bytes, write_bytes, read_time, write_time + FROM check_disks WHERE check_id = $1`, + [checkId] + ), + this.pool.query( + `SELECT name, bytes_sent, bytes_recv, packets_sent, packets_recv, + err_in, err_out, drop_in, drop_out, fifo_in, fifo_out + FROM check_network_interfaces WHERE check_id = $1`, + [checkId] + ), + this.pool.query(`SELECT metrics, error FROM check_errors WHERE check_id = $1`, [checkId]), + ]); + return { + disk: diskResult.rows.map((d) => ({ + device: d.device, + mountpoint: d.mountpoint, + total_bytes: Number(d.total_bytes), + free_bytes: Number(d.free_bytes), + used_bytes: Number(d.used_bytes), + usage_percent: d.usage_percent, + total_inodes: Number(d.total_inodes), + free_inodes: Number(d.free_inodes), + used_inodes: Number(d.used_inodes), + inodes_usage_percent: d.inodes_usage_percent, + read_bytes: Number(d.read_bytes), + write_bytes: Number(d.write_bytes), + read_time: Number(d.read_time), + write_time: Number(d.write_time), + })), + net: netResult.rows.map((n) => ({ + name: n.name, + bytes_sent: Number(n.bytes_sent), + bytes_recv: Number(n.bytes_recv), + packets_sent: Number(n.packets_sent), + packets_recv: Number(n.packets_recv), + err_in: Number(n.err_in), + err_out: Number(n.err_out), + drop_in: Number(n.drop_in), + drop_out: Number(n.drop_out), + fifo_in: Number(n.fifo_in), + fifo_out: Number(n.fifo_out), + })), + errors: errorsResult.rows.map((e) => ({ metric: e.metrics ?? [], err: e.error ?? "" })), + }; + }; + + private toEntity = (row: MonitorRow): Monitor => ({ + id: row.id, + userId: row.user_id, + teamId: row.team_id, + name: row.name, + description: row.description ?? undefined, + status: row.status as MonitorStatus, + statusWindow: row.status_window ?? [], + statusWindowSize: row.status_window_size, + statusWindowThreshold: row.status_window_threshold, + type: row.type, + ignoreTlsErrors: row.ignore_tls_errors, + useAdvancedMatching: row.use_advanced_matching, + jsonPath: row.json_path ?? undefined, + expectedValue: row.expected_value ?? undefined, + matchMethod: row.match_method ?? undefined, + url: row.url ?? "", + port: row.port ?? undefined, + isActive: row.is_active, + interval: row.interval_ms, + uptimePercentage: row.uptime_percentage ?? undefined, + notifications: [], + secret: row.secret ?? undefined, + cpuAlertThreshold: row.cpu_alert_threshold, + cpuAlertCounter: row.cpu_alert_counter, + memoryAlertThreshold: row.memory_alert_threshold, + memoryAlertCounter: row.memory_alert_counter, + diskAlertThreshold: row.disk_alert_threshold, + diskAlertCounter: row.disk_alert_counter, + tempAlertThreshold: row.temp_alert_threshold, + tempAlertCounter: row.temp_alert_counter, + selectedDisks: row.selected_disks ?? [], + gameId: row.game_id ?? undefined, + grpcServiceName: row.grpc_service_name ?? undefined, + group: row.monitor_group, + geoCheckEnabled: row.geo_check_enabled, + geoCheckLocations: row.geo_check_locations ?? [], + geoCheckInterval: row.geo_check_interval_ms, + recentChecks: [], + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }); +} diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts new file mode 100644 index 000000000..b2c369c86 --- /dev/null +++ b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts @@ -0,0 +1,140 @@ +import type { Pool } from "pg"; +import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js"; +import type { Notification, NotificationChannel } from "@/types/notification.js"; +import { AppError } from "@/utils/AppError.js"; + +interface NotificationRow { + id: string; + user_id: string; + team_id: string; + type: NotificationChannel; + notification_name: string; + address: string | null; + phone: string | null; + homeserver_url: string | null; + room_id: string | null; + access_token: string | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, + homeserver_url, room_id, access_token, created_at, updated_at`; + +export class TimescaleNotificationsRepository implements INotificationsRepository { + constructor(private pool: Pool) {} + + create = async (data: Partial): Promise => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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 => { + if (!ids.length) { + return []; + } + const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]); + return result.rows.map(this.toEntity); + }; + + findByTeamId = async (teamId: string): Promise => { + const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]); + return result.rows.map(this.toEntity); + }; + + updateById = async (id: string, teamId: string, patch: Partial): Promise => { + 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( + `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 => { + const result = await this.pool.query(`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(), + }); +} diff --git a/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts new file mode 100644 index 000000000..4c5f0fe6a --- /dev/null +++ b/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts @@ -0,0 +1,59 @@ +import type { Pool } from "pg"; +import crypto from "crypto"; +import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js"; +import type { RecoveryToken } from "@/types/recoveryToken.js"; +import { AppError } from "@/utils/AppError.js"; + +interface RecoveryTokenRow { + id: string; + email: string; + token: string; + expiry: Date | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, email, token, expiry, created_at, updated_at`; + +export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository { + constructor(private pool: Pool) {} + + create = async (email: string): Promise => { + const token = crypto.randomBytes(32).toString("hex"); + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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 => { + 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(), + }); +} diff --git a/server/src/repositories/settings/TimescaleSettingsRepository.ts b/server/src/repositories/settings/TimescaleSettingsRepository.ts new file mode 100644 index 000000000..82038b23f --- /dev/null +++ b/server/src/repositories/settings/TimescaleSettingsRepository.ts @@ -0,0 +1,217 @@ +import type { Pool } from "pg"; +import type { ISettingsRepository } from "./ISettingsRepository.js"; +import type { Settings, SettingsUpdate } from "@/types/settings.js"; + +interface SettingsRow { + id: string; + check_ttl: number; + language: string | null; + jwt_secret: string | null; + pagespeed_api_key: string | null; + system_email_host: string | null; + system_email_port: number | null; + system_email_address: string | null; + system_email_password: string | null; + system_email_user: string | null; + system_email_connection_host: string | null; + system_email_tls_servername: string | null; + system_email_secure: boolean | null; + system_email_pool: boolean | null; + system_email_ignore_tls: boolean | null; + system_email_require_tls: boolean | null; + system_email_reject_unauthorized: boolean | null; + show_url: boolean | null; + version: string | null; + threshold_cpu_usage: number | null; + threshold_memory_usage: number | null; + threshold_disk_usage: number | null; + threshold_temperature: number | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key, + system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, + system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, + system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, + show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, + created_at, updated_at`; + +export class TimescaleSettingsRepository implements ISettingsRepository { + constructor(private pool: Pool) {} + + create = async (settings: Partial): Promise => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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 => { + 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(`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); + } + return this.toEntity(row); + }; + + deleteLegacy = async (): Promise => { + // 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(), + }); +} diff --git a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts b/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts new file mode 100644 index 000000000..583e775b5 --- /dev/null +++ b/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts @@ -0,0 +1,260 @@ +import type { Pool } from "pg"; +import type { IStatusPagesRepository } from "./IStatusPagesRepository.js"; +import type { StatusPage, StatusPageType } from "@/types/statusPage.js"; +import { AppError } from "@/utils/AppError.js"; + +const parsePostgresArray = (value: unknown): StatusPageType[] => { + if (typeof value !== "string") return []; + const inner = value.replace(/^\{|\}$/g, ""); + if (inner === "") return []; + return inner.split(",") as StatusPageType[]; +}; + +interface StatusPageRow { + id: string; + user_id: string; + team_id: string; + types: StatusPageType[]; + company_name: string; + url: string; + timezone: string | null; + color: string; + logo_data: Buffer | null; + logo_content_type: string | null; + is_published: boolean; + show_charts: boolean; + show_uptime_percentage: boolean; + show_admin_login_link: boolean; + show_infrastructure: boolean; + custom_css: string | null; + created_at: Date; + updated_at: Date; +} + +const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color, + logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, + show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`; + +export class TimescaleStatusPagesRepository implements IStatusPagesRepository { + constructor(private pool: Pool) {} + + create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query(`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 => { + const result = await this.pool.query(`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 & { removeLogo?: string } + ): Promise => { + 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( + `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(`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 => { + // Load entity before delete (join tables cascade) + const existing = await this.pool.query(`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 => { + // 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 => { + 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(), + }; + }; +} diff --git a/server/src/repositories/teams/TimescaleTeamsRepository.ts b/server/src/repositories/teams/TimescaleTeamsRepository.ts new file mode 100644 index 000000000..be16b7790 --- /dev/null +++ b/server/src/repositories/teams/TimescaleTeamsRepository.ts @@ -0,0 +1,24 @@ +import type { Pool } from "pg"; +import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js"; +import { Team } from "@/types/team.js"; + +export class TimescaleTeamsRepository implements ITeamsRepository { + constructor(private pool: Pool) {} + + create = async (email: string): Promise => { + 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 => { + 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(), + }); +} diff --git a/server/src/repositories/users/TimescaleUsersRepository.ts b/server/src/repositories/users/TimescaleUsersRepository.ts new file mode 100644 index 000000000..5d7da51b8 --- /dev/null +++ b/server/src/repositories/users/TimescaleUsersRepository.ts @@ -0,0 +1,222 @@ +import type { Pool } from "pg"; +import { IUsersRepository } from "./IUsersRepository.js"; +import type { User, UserRole } from "@/types/user.js"; +import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; +import { ParseBoolean } from "@/utils/utils.js"; +import { AppError } from "@/utils/AppError.js"; + +const SERVICE_NAME = "TimescaleUsersRepository"; + +// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array +const parsePostgresArray = (value: unknown): UserRole[] => { + if (typeof value !== "string") return []; + const inner = value.replace(/^\{|\}$/g, ""); + if (inner === "") return []; + return inner.split(",") as UserRole[]; +}; + +interface UserRow { + id: string; + team_id: string; + first_name: string; + last_name: string; + email: string; + password: string; + avatar_image: string | null; + profile_image: Buffer | null; + profile_image_content_type: string | null; + is_active: boolean; + is_verified: boolean; + roles: UserRole[]; + check_ttl: number | null; + created_at: Date; + updated_at: Date; +} + +export class TimescaleUsersRepository implements IUsersRepository { + constructor(private pool: Pool) {} + + create = async (user: Partial, imageFile?: Express.Multer.File | null): Promise => { + 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( + `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 => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query( + `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 => { + const result = await this.pool.query( + `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, file?: Express.Multer.File | null): Promise => { + 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( + `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 => { + const result = await this.pool.query( + `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 => { + 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(), + }); +} diff --git a/server/src/service/business/geoChecksService.ts b/server/src/service/business/geoChecksService.ts index 315c57858..95b945f2a 100644 --- a/server/src/service/business/geoChecksService.ts +++ b/server/src/service/business/geoChecksService.ts @@ -20,7 +20,7 @@ export interface IGeoChecksService { dateRange: string; page?: number; rowsPerPage?: number; - continent: GeoContinent | GeoContinent[]; + continent?: GeoContinent | GeoContinent[]; }): Promise; } diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 519d2f390..f2daff550 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -1,5 +1,5 @@ import { ISettingsRepository } from "@/repositories/index.js"; -import { Settings, SettingsUpdate } from "@/types/index.js"; +import { type DbType, Settings, SettingsUpdate } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; import { ValidatedEnv } from "@/validation/envValidation.js"; import type { StringValue } from "ms"; @@ -12,6 +12,7 @@ export type EnvConfig = { logLevel: string; clientHost: string; dbConnectionString: string; + dbType: DbType; }; export interface ISettingsService { @@ -25,10 +26,9 @@ export interface ISettingsService { export class SettingsService implements ISettingsService { static SERVICE_NAME = SERVICE_NAME; private settings: EnvConfig; - private settingsRepository: ISettingsRepository; + private settingsRepository: ISettingsRepository | null = null; - constructor(settingsRepository: ISettingsRepository, env: ValidatedEnv) { - this.settingsRepository = settingsRepository; + constructor(env: ValidatedEnv) { this.settings = { jwtSecret: env.JWT_SECRET, jwtTTL: env.TOKEN_TTL as StringValue, @@ -36,9 +36,14 @@ export class SettingsService implements ISettingsService { logLevel: env.LOG_LEVEL, clientHost: env.CLIENT_HOST, dbConnectionString: env.DB_CONNECTION_STRING, + dbType: env.DB_TYPE, }; } + setRepository(settingsRepository: ISettingsRepository) { + this.settingsRepository = settingsRepository; + } + get serviceName() { return SettingsService.SERVICE_NAME; } @@ -54,18 +59,30 @@ export class SettingsService implements ISettingsService { return this.settings; } + private getRepository(): ISettingsRepository { + if (!this.settingsRepository) { + throw new AppError({ + message: "Settings repository not initialized. Call setRepository() after DB connect.", + status: 500, + service: SERVICE_NAME, + }); + } + return this.settingsRepository; + } + updateDbSettings = async (newSettings: SettingsUpdate) => { - return await this.settingsRepository.update(newSettings); + return await this.getRepository().update(newSettings); }; getDBSettings = async () => { + const repo = this.getRepository(); // Remove any old settings - await this.settingsRepository.deleteLegacy(); + await repo.deleteLegacy(); - let settings = await this.settingsRepository.findSingleton(); + let settings = await repo.findSingleton(); if (settings === null) { - await this.settingsRepository.create({}); - settings = await this.settingsRepository.findSingleton(); + await repo.create({}); + settings = await repo.findSingleton(); } if (!settings) { diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index 405dc3efe..bcedfbe89 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -1,3 +1,6 @@ +export const DbTypes = ["timescaledb", "mongodb"] as const; +export type DbType = (typeof DbTypes)[number]; + export interface SettingsThresholds { cpu?: number; memory?: number; diff --git a/server/src/validation/envValidation.ts b/server/src/validation/envValidation.ts index 2b0e36fcf..447748516 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/validation/envValidation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { DbTypes } from "@/types/settings.js"; const envSchema = z.object({ // Server Configuration @@ -8,6 +9,7 @@ const envSchema = z.object({ // Database DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"), + DB_TYPE: z.enum(DbTypes).default("mongodb"), // JWT Authentication JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), diff --git a/server/src/validation/incidentValidation.ts b/server/src/validation/incidentValidation.ts index acbc857dc..2047eefa5 100644 --- a/server/src/validation/incidentValidation.ts +++ b/server/src/validation/incidentValidation.ts @@ -7,7 +7,7 @@ import { booleanCoercion } from "./shared.js"; export const getIncidentsByTeamQueryValidation = z.object({ sortOrder: z.enum(["asc", "desc"]), - dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).default("all"), page: z.coerce.number().int().min(0), rowsPerPage: z.coerce.number().int().min(1), status: booleanCoercion.optional(),