Merge remote-tracking branch 'origin/develop' into feat/pagespeed-mobile-strat

This commit is contained in:
Alex committed 2026-05-11 11:11:13 -07:00
commit 78fdc548bb
395 files changed
+60597 -29063

No files matched your search

+12 -35
View File
@@ -29,6 +29,8 @@ import {
MatrixProvider,
TeamsProvider,
TelegramProvider,
PushoverProvider,
TwilioProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -62,8 +64,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js";
// Third-party
import { Resolver } from "dns/promises";
import axios from "axios";
import got from "got";
import ping from "ping";
@@ -98,19 +102,6 @@ import {
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
TimescaleMonitorsRepository,
TimescaleChecksRepository,
TimescaleGeoChecksRepository,
TimescaleMonitorStatsRepository,
TimescaleStatusPagesRepository,
TimescaleUsersRepository,
TimescaleInvitesRepository,
TimescaleRecoveryTokensRepository,
TimescaleNotificationsRepository,
TimescaleIncidentsRepository,
TimescaleTeamsRepository,
TimescaleMaintenanceWindowsRepository,
TimescaleSettingsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
@@ -126,7 +117,6 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import TimescaleDB from "@/db/TimescaleDB.js";
import { AppError } from "@/utils/AppError.js";
export type InitializedServices = {
@@ -183,8 +173,6 @@ export const initializeServices = async ({
if (dbType === "mongodb") {
db = new MongoDB(logger, envSettings);
} else if (dbType === "timescaledb") {
db = new TimescaleDB(logger, envSettings);
}
if (!db) {
@@ -224,23 +212,7 @@ export const initializeServices = async ({
teamsRepository = new MongoTeamsRepository();
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
} else {
const pool = db.getPool();
if (!pool) {
throw new Error("Failed to get database pool");
}
monitorsRepository = new TimescaleMonitorsRepository(pool);
checksRepository = new TimescaleChecksRepository(pool);
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
usersRepository = new TimescaleUsersRepository(pool);
invitesRepository = new TimescaleInvitesRepository(pool);
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
settingsRepository = new TimescaleSettingsRepository(pool);
notificationsRepository = new TimescaleNotificationsRepository(pool);
incidentsRepository = new TimescaleIncidentsRepository(pool);
teamsRepository = new TimescaleTeamsRepository(pool);
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
throw new AppError({ message: "Unsupported database type", status: 500 });
}
// Inject settings repository into settings service (now that DB is connected)
@@ -256,6 +228,7 @@ export const initializeServices = async ({
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const dnsProvider = new DNSProvider(() => new Resolver());
const networkService = new NetworkService(axios, logger, [
pingProvider,
@@ -267,6 +240,7 @@ export const initializeServices = async ({
gameProvider,
grpcProvider,
webSocketProvider,
dnsProvider,
]);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
@@ -298,6 +272,8 @@ export const initializeServices = async ({
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const pushoverProvider = new PushoverProvider(logger);
const twilioProvider = new TwilioProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -310,6 +286,8 @@ export const initializeServices = async ({
matrixProvider,
teamsProvider,
telegramProvider,
pushoverProvider,
twilioProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -364,7 +342,6 @@ export const initializeServices = async ({
});
const monitorService = new MonitorService({
jobQueue: superSimpleQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -375,7 +352,7 @@ export const initializeServices = async ({
incidentsRepository,
});
const statusPageService = new StatusPageService(statusPagesRepository);
const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
const services = {
settingsService,
@@ -29,6 +29,14 @@ export const optionalString = (value: unknown, fieldName: string): string | unde
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
export const extractString = (value: unknown): string | undefined => {
const candidate = Array.isArray(value) ? value[0] : value;
if (typeof candidate !== "string" || candidate.length === 0) {
return undefined;
}
return candidate;
};
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
+4 -3
View File
@@ -1,6 +1,6 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
import { requireTeamId, requireUserId, requireUserEmail, extractString } from "./controllerUtils.js";
import { IIncidentService } from "@/service/index.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
@@ -85,12 +85,13 @@ class IncidentController implements IIncidentController {
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const userEmail = requireUserEmail(req.user?.email);
const incidentId = req.params?.incidentId;
const incidentId = extractString(req.params?.incidentId);
if (!incidentId) {
throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 });
}
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, req?.body?.comment, userEmail);
const comment = extractString(req.body?.comment);
const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, comment, userEmail);
return res.status(200).json({
success: true,
+29 -1
View File
@@ -14,12 +14,12 @@ import {
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
bulkPauseMonitorBodyValidation,
} from "@/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService, INotificationsService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
const SERVICE_NAME = "monitorController";
@@ -36,6 +36,7 @@ export interface IMonitorController {
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
@@ -307,6 +308,33 @@ class MonitorController implements IMonitorController {
}
};
bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body);
const { monitorIds, pause } = validatedBody;
const teamId = requireTeamId(req.user?.teamId);
const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause });
const action = pause ? "paused" : "resumed";
const monitorStr = monitors.length === 1 ? "monitor" : "monitors";
let msg = `${monitors.length} ${monitorStr} ${action} successfully`;
if (failedCount > 0) {
msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`;
}
return res.status(200).json({
success: true,
msg,
data: monitors,
});
} catch (error) {
next(error);
}
};
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = requireUserId(req.user?.id);
@@ -37,13 +37,9 @@ class NotificationController implements INotificationController {
const notification = testNotificationBodyValidation.parse(req.body);
const success = await this.notificationsService.sendTestNotification(notification);
if (!success) {
throw new AppError({ message: "Sending notification failed", status: 500 });
}
return res.status(200).json({
success: true,
msg: "Notification sent successfully",
success,
msg: success ? "Notification sent successfully" : "Notification could not be sent — check the destination details.",
details: { service: SERVICE_NAME },
});
} catch (error) {
@@ -89,6 +89,7 @@ class SettingsController implements ISettingsController {
systemEmailHost,
systemEmailPort,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailUser,
systemEmailConnectionHost,
@@ -112,6 +113,7 @@ class SettingsController implements ISettingsController {
systemEmailPort,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailSecure,
@@ -66,6 +66,7 @@ class StatusPageController implements IStatusPageController {
if (req.file) {
imageValidation.parse(req.file);
}
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id as string;
if (!statusPageId) {
@@ -127,7 +128,10 @@ class StatusPageController implements IStatusPageController {
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: { statusPage, monitors: normalizedMonitors },
data: {
statusPage,
monitors: normalizedMonitors,
},
});
} catch (error) {
next(error);
@@ -0,0 +1,33 @@
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
/**
* When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming
* theme fields before persisting so an admin client can't accidentally store
* a value the public page won't honour. Whatever a caller sends here for
* `theme` / `themeMode` is silently ignored — the rest of the body is
* preserved verbatim.
*
* The strip is silent (not a 400) because the kill switch is an ops-side
* concern: API clients should not have to know whether the operator has
* disabled themes today, only that their saved value won't take effect.
* If we later decide we'd rather fail loudly, change this to throw a
* 400 AppError; downstream consumers already handle it.
*/
export const withoutThemeFields = (body: Record<string, unknown>): Record<string, unknown> => {
const rest = { ...body };
delete rest.theme;
delete rest.themeMode;
return rest;
};
/**
* When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page
* should see the canonical default theme / mode regardless of what's stored
* in Mongo. Whatever the user previously chose stays in the DB so flipping
* the flag back on resurfaces their selection.
*/
export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
-3
View File
@@ -1,7 +1,4 @@
import { Pool } from "pg";
export interface IDb {
connect(): Promise<void>;
disconnect(): Promise<void>;
getPool(): Pool | null;
}
+6 -7
View File
@@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
const SERVICE_NAME = "MongoDB";
import { IDb } from "@/db/IDb.js";
import { Pool } from "pg";
class MongoDB implements IDb {
static SERVICE_NAME = SERVICE_NAME;
@@ -35,6 +34,12 @@ class MongoDB implements IDb {
setDefaultsOnInsert: true,
}
);
// Run migrations BEFORE syncIndexes. The 0006 dedupe migration must run before
// the unique index on MonitorStats.monitorId is enforced, otherwise databases
// with stray duplicates (caused by the pre-fix race in updateRunningStats)
// will crash at startup when the unique index fails to build.
await runMigrations();
// Sync indexes
const models = mongoose.modelNames();
for (const modelName of models) {
@@ -47,8 +52,6 @@ class MongoDB implements IDb {
service: SERVICE_NAME,
method: "connect",
});
await runMigrations();
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -75,10 +78,6 @@ class MongoDB implements IDb {
});
}
};
getPool = () => {
return null;
};
}
export default MongoDB;
-105
View File
@@ -1,105 +0,0 @@
import { IDb } from "@/db/IDb.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { Client, Pool } from "pg";
import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js";
const SERVICE_NAME = "TimescaleDB";
class TimescaleDB implements IDb {
private pool: Pool | null = null;
constructor(
private logger: ILogger,
private envSettings: EnvConfig
) {}
private ensureDatabaseExists = async (connectionString: string) => {
const url = new URL(connectionString);
const dbName = url.pathname.slice(1); // Remove leading "/"
// Connect to the default "postgres" database
url.pathname = "/postgres";
const client = new Client({ connectionString: url.toString() });
await client.connect();
const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
if (result.rowCount === 0) {
await client.query(`CREATE DATABASE "${dbName}"`);
this.logger.info({
message: `Created database "${dbName}"`,
service: SERVICE_NAME,
method: "ensureDatabaseExists",
});
}
await client.end();
};
connect = async () => {
try {
const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate";
await this.ensureDatabaseExists(connectionString);
this.pool = new Pool({ connectionString });
const client = await this.pool.connect();
client.release();
// Ensure TimescaleDB extension is enabled
await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;");
this.logger.info({
message: "Connected to TimescaleDB",
service: SERVICE_NAME,
method: "connect",
});
await runTimescaleDBMigrations(this.pool, this.logger);
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "connect",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
disconnect = async () => {
try {
if (this.pool === null) {
return;
}
this.logger.info({
message: "Disconnecting from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
await this.pool.end();
this.pool = null;
this.logger.info({
message: "Disconnected from TimescaleDB",
service: SERVICE_NAME,
method: "disconnect",
});
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "disconnect",
stack: error instanceof Error ? error.stack : undefined,
});
}
};
getPool = (): Pool => {
if (this.pool === null) {
throw new Error("TimescaleDB is not connected. Call connect() first.");
}
return this.pool;
};
}
export default TimescaleDB;
@@ -0,0 +1,78 @@
import { MonitorStatsModel } from "../models/MonitorStats.js";
import { logger } from "@/utils/logger.js";
/**
* Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced.
*
* 0003 ran the same dedupe, but the pre-fix race in StatusService.updateRunningStats
* could create new duplicates after 0003 completed. This migration re-runs the dedupe so
* the unique index added in the same PR can be built without failing.
*
* Keeps the document with the highest totalChecks for each monitorId (most data) and
* deletes the rest. Falls back to most recent updatedAt when totalChecks tie.
*/
export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise<void> {
const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex";
try {
logger.info({ service: SERVICE_NAME, message: "Starting pre-unique-index dedupe of MonitorStats" });
const duplicates = await MonitorStatsModel.aggregate([
{
$group: {
_id: "$monitorId",
docs: { $push: { id: "$_id", totalChecks: "$totalChecks", updatedAt: "$updatedAt" } },
count: { $sum: 1 },
},
},
{ $match: { count: { $gt: 1 } } },
]);
if (duplicates.length === 0) {
logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Found ${duplicates.length} monitors with duplicate stats`,
});
let totalDeleted = 0;
type DocInfo = { id: string; totalChecks: number; updatedAt: Date };
for (const duplicate of duplicates) {
const monitorId = duplicate._id;
const docs: DocInfo[] = duplicate.docs;
// Sort by totalChecks desc, then updatedAt desc — keep the winner, delete the rest.
docs.sort((a, b) => {
const checksDiff = (b.totalChecks ?? 0) - (a.totalChecks ?? 0);
if (checksDiff !== 0) return checksDiff;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const toDelete = docs.slice(1).map((doc) => doc.id);
if (toDelete.length > 0) {
const result = await MonitorStatsModel.deleteMany({ _id: { $in: toDelete } });
totalDeleted += result.deletedCount ?? 0;
logger.debug({
service: SERVICE_NAME,
message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`,
});
}
}
logger.info({
service: SERVICE_NAME,
message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats dedupe: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -4,6 +4,7 @@ import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStat
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -17,6 +18,7 @@ const migrations: MigrationEntry[] = [
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
{ name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex },
];
const runMigrations = async (logger?: ILogger) => {
@@ -1,50 +0,0 @@
import type { Pool } from "pg";
export const createEnums = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
};
export const dropEnums = async (pool: Pool) => {
await pool.query(`
DROP TYPE IF EXISTS match_method;
DROP TYPE IF EXISTS status_page_type;
DROP TYPE IF EXISTS duration_unit;
DROP TYPE IF EXISTS incident_resolution_type;
DROP TYPE IF EXISTS user_role;
DROP TYPE IF EXISTS notification_type;
DROP TYPE IF EXISTS monitor_status;
DROP TYPE IF EXISTS monitor_type;
`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createTeams = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email);
`);
};
export const dropTeams = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS teams;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createUsers = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
avatar_image TEXT,
profile_image BYTEA,
profile_image_content_type TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
check_ttl INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
`);
};
export const dropUsers = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS users;`);
};
@@ -1,69 +0,0 @@
import type { Pool } from "pg";
export const createMonitors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
name TEXT NOT NULL,
description TEXT,
type monitor_type NOT NULL,
status monitor_status NOT NULL DEFAULT 'initializing',
-- HTTP / Website monitoring
url TEXT,
port INTEGER,
ignore_tls_errors BOOLEAN DEFAULT FALSE,
use_advanced_matching BOOLEAN DEFAULT FALSE,
json_path TEXT,
expected_value TEXT,
match_method match_method,
secret TEXT,
-- Scheduling
interval_ms INTEGER NOT NULL DEFAULT 60000,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Uptime tracking
status_window BOOLEAN[],
status_window_size INTEGER DEFAULT 5,
status_window_threshold INTEGER DEFAULT 60,
uptime_percentage DOUBLE PRECISION,
-- Infrastructure alert thresholds
cpu_alert_threshold INTEGER,
cpu_alert_counter INTEGER,
memory_alert_threshold INTEGER,
memory_alert_counter INTEGER,
disk_alert_threshold INTEGER,
disk_alert_counter INTEGER,
temp_alert_threshold INTEGER,
temp_alert_counter INTEGER,
selected_disks TEXT[],
-- Game monitoring
game_id TEXT,
grpc_service_name TEXT,
-- Grouping
monitor_group TEXT,
-- Geo checks
geo_check_enabled BOOLEAN DEFAULT FALSE,
geo_check_locations TEXT[],
geo_check_interval_ms INTEGER DEFAULT 300000,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type);
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id);
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status);
`);
};
export const dropMonitors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitors;`);
};
@@ -1,30 +0,0 @@
import type { Pool } from "pg";
export const createNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
type notification_type NOT NULL,
notification_name TEXT,
address TEXT,
phone TEXT,
-- Matrix-specific fields
homeserver_url TEXT,
room_id TEXT,
access_token TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id);
`);
};
export const dropNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS notifications;`);
};
@@ -1,18 +0,0 @@
import type { Pool } from "pg";
export const createMonitorNotifications = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_notifications (
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
PRIMARY KEY (monitor_id, notification_id)
);
CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification
ON monitor_notifications (notification_id);
`);
};
export const dropMonitorNotifications = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createIncidents = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
status BOOLEAN,
message TEXT,
status_code INTEGER,
resolution_type incident_resolution_type,
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
resolved_by_email TEXT,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status);
CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC);
CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status);
CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status);
CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC);
`);
};
export const dropIncidents = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS incidents;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createMonitorStats = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS monitor_stats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE,
avg_response_time DOUBLE PRECISION DEFAULT 0,
max_response_time DOUBLE PRECISION DEFAULT 0,
total_checks BIGINT DEFAULT 0,
total_up_checks BIGINT DEFAULT 0,
total_down_checks BIGINT DEFAULT 0,
uptime_percentage DOUBLE PRECISION DEFAULT 0,
last_check_timestamp TIMESTAMPTZ,
last_response_time DOUBLE PRECISION DEFAULT 0,
time_of_last_failure TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
export const dropMonitorStats = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS monitor_stats;`);
};
@@ -1,105 +0,0 @@
import type { Pool } from "pg";
export const createChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
status BOOLEAN,
response_time INTEGER,
status_code INTEGER,
message TEXT,
-- HTTP request timings
timing_start DOUBLE PRECISION,
timing_socket DOUBLE PRECISION,
timing_lookup DOUBLE PRECISION,
timing_connect DOUBLE PRECISION,
timing_secure_connect DOUBLE PRECISION,
timing_upload DOUBLE PRECISION,
timing_response DOUBLE PRECISION,
timing_end DOUBLE PRECISION,
phase_wait DOUBLE PRECISION,
phase_dns DOUBLE PRECISION,
phase_tcp DOUBLE PRECISION,
phase_tls DOUBLE PRECISION,
phase_request DOUBLE PRECISION,
phase_first_byte DOUBLE PRECISION,
phase_download DOUBLE PRECISION,
phase_total DOUBLE PRECISION,
-- CPU metrics
cpu_physical_core INTEGER,
cpu_logical_core INTEGER,
cpu_frequency DOUBLE PRECISION,
cpu_current_frequency DOUBLE PRECISION,
cpu_temperature DOUBLE PRECISION[],
cpu_free_percent DOUBLE PRECISION,
cpu_usage_percent DOUBLE PRECISION,
-- Memory metrics
mem_total_bytes BIGINT,
mem_available_bytes BIGINT,
mem_used_bytes BIGINT,
mem_usage_percent DOUBLE PRECISION,
-- Host info
host_os TEXT,
host_platform TEXT,
host_kernel_version TEXT,
host_pretty_name TEXT,
-- Capture agent info
capture_version TEXT,
capture_mode TEXT,
-- Lighthouse / PageSpeed scores
lighthouse_performance DOUBLE PRECISION,
lighthouse_accessibility DOUBLE PRECISION,
lighthouse_best_practices DOUBLE PRECISION,
lighthouse_seo DOUBLE PRECISION,
-- Lighthouse audit details
audit_cls_score DOUBLE PRECISION,
audit_cls_value DOUBLE PRECISION,
audit_cls_display TEXT,
audit_si_score DOUBLE PRECISION,
audit_si_value DOUBLE PRECISION,
audit_si_display TEXT,
audit_fcp_score DOUBLE PRECISION,
audit_fcp_value DOUBLE PRECISION,
audit_fcp_display TEXT,
audit_lcp_score DOUBLE PRECISION,
audit_lcp_value DOUBLE PRECISION,
audit_lcp_display TEXT,
audit_tbt_score DOUBLE PRECISION,
audit_tbt_value DOUBLE PRECISION,
audit_tbt_display TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
ON checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_time
ON checks (team_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time
ON checks (monitor_id, monitor_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_team_status_time
ON checks (team_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_status_code
ON checks (status_code, created_at DESC);
`);
};
export const dropChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS checks;`);
};
@@ -1,37 +0,0 @@
import type { Pool } from "pg";
export const createCheckDisks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_disks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
device TEXT,
mountpoint TEXT,
total_bytes BIGINT,
free_bytes BIGINT,
used_bytes BIGINT,
usage_percent DOUBLE PRECISION,
total_inodes BIGINT,
free_inodes BIGINT,
used_inodes BIGINT,
inodes_usage_percent DOUBLE PRECISION,
read_bytes BIGINT,
write_bytes BIGINT,
read_time BIGINT,
write_time BIGINT
);
SELECT create_hypertable('check_disks', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_disks_check
ON check_disks (check_id, check_created_at);
`);
};
export const dropCheckDisks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_disks;`);
};
@@ -1,34 +0,0 @@
import type { Pool } from "pg";
export const createCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_network_interfaces (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
name TEXT,
bytes_sent BIGINT,
bytes_recv BIGINT,
packets_sent BIGINT,
packets_recv BIGINT,
err_in BIGINT,
err_out BIGINT,
drop_in BIGINT,
drop_out BIGINT,
fifo_in BIGINT,
fifo_out BIGINT
);
SELECT create_hypertable('check_network_interfaces', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_net_check
ON check_network_interfaces (check_id, check_created_at);
`);
};
export const dropCheckNetworkInterfaces = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`);
};
@@ -1,25 +0,0 @@
import type { Pool } from "pg";
export const createCheckErrors = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS check_errors (
id UUID NOT NULL DEFAULT gen_random_uuid(),
check_id UUID NOT NULL,
check_created_at TIMESTAMPTZ NOT NULL,
metrics TEXT[],
error TEXT
);
SELECT create_hypertable('check_errors', 'check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_check_errors_check
ON check_errors (check_id, check_created_at);
`);
};
export const dropCheckErrors = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS check_errors;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createGeoChecks = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_checks (
id UUID NOT NULL DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL,
team_id UUID NOT NULL,
monitor_type monitor_type NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
SELECT create_hypertable('geo_checks', 'created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time
ON geo_checks (monitor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time
ON geo_checks (team_id, created_at DESC);
`);
};
export const dropGeoChecks = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_checks;`);
};
@@ -1,46 +0,0 @@
import type { Pool } from "pg";
export const createGeoCheckResults = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS geo_check_results (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geo_check_id UUID NOT NULL,
geo_check_created_at TIMESTAMPTZ NOT NULL,
-- Status
status BOOLEAN,
status_code INTEGER,
-- Location
location_continent TEXT,
location_region TEXT,
location_country TEXT,
location_state TEXT,
location_city TEXT,
location_longitude DOUBLE PRECISION,
location_latitude DOUBLE PRECISION,
-- Timings
timing_total DOUBLE PRECISION,
timing_dns DOUBLE PRECISION,
timing_tcp DOUBLE PRECISION,
timing_tls DOUBLE PRECISION,
timing_first_byte DOUBLE PRECISION,
timing_download DOUBLE PRECISION
);
SELECT create_hypertable('geo_check_results', 'geo_check_created_at',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS idx_geo_results_check
ON geo_check_results (geo_check_id, geo_check_created_at);
CREATE INDEX IF NOT EXISTS idx_geo_results_country
ON geo_check_results (location_country, geo_check_created_at DESC);
`);
};
export const dropGeoCheckResults = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS geo_check_results;`);
};
@@ -1,22 +0,0 @@
import type { Pool } from "pg";
export const createInvites = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[],
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry);
`);
};
export const dropInvites = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS invites;`);
};
@@ -1,20 +0,0 @@
import type { Pool } from "pg";
export const createRecoveryTokens = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS recovery_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
token TEXT NOT NULL,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry);
`);
};
export const dropRecoveryTokens = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`);
};
@@ -1,29 +0,0 @@
import type { Pool } from "pg";
export const createMaintenanceWindows = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS maintenance_windows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
active BOOLEAN NOT NULL DEFAULT TRUE,
name TEXT,
duration INTEGER,
duration_unit duration_unit,
repeat INTEGER,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
expiry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id);
CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id);
CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry);
`);
};
export const dropMaintenanceWindows = async (pool: Pool) => {
await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`);
};
@@ -1,51 +0,0 @@
import type { Pool } from "pg";
export const createStatusPages = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS status_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
team_id UUID NOT NULL REFERENCES teams(id),
types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[],
company_name TEXT,
url TEXT NOT NULL UNIQUE,
timezone TEXT,
color TEXT DEFAULT '#4169E1',
logo_data BYTEA,
logo_content_type TEXT,
is_published BOOLEAN DEFAULT FALSE,
show_charts BOOLEAN DEFAULT FALSE,
show_uptime_percentage BOOLEAN DEFAULT FALSE,
show_admin_login_link BOOLEAN DEFAULT FALSE,
show_infrastructure BOOLEAN DEFAULT FALSE,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id);
CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url);
CREATE TABLE IF NOT EXISTS status_page_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
CREATE TABLE IF NOT EXISTS status_page_sub_monitors (
status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
sort_order INTEGER DEFAULT 0,
PRIMARY KEY (status_page_id, monitor_id)
);
`);
};
export const dropStatusPages = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS status_page_sub_monitors;
DROP TABLE IF EXISTS status_page_monitors;
DROP TABLE IF EXISTS status_pages;
`);
};
@@ -1,62 +0,0 @@
import type { Pool } from "pg";
export const createAppSettings = async (pool: Pool) => {
await pool.query(`
CREATE TABLE IF NOT EXISTS app_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
check_ttl INTEGER DEFAULT 30,
language TEXT,
jwt_secret TEXT,
pagespeed_api_key TEXT,
-- SMTP / Email settings
system_email_host TEXT,
system_email_port INTEGER,
system_email_address TEXT,
system_email_password TEXT,
system_email_user TEXT,
system_email_connection_host TEXT,
system_email_tls_servername TEXT,
system_email_secure BOOLEAN,
system_email_pool BOOLEAN,
system_email_ignore_tls BOOLEAN,
system_email_require_tls BOOLEAN,
system_email_reject_unauthorized BOOLEAN,
show_url BOOLEAN,
version TEXT,
-- Global infrastructure thresholds
threshold_cpu_usage INTEGER,
threshold_memory_usage INTEGER,
threshold_disk_usage INTEGER,
threshold_temperature INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enforce singleton: only one row allowed
CREATE OR REPLACE FUNCTION enforce_singleton_app_settings()
RETURNS TRIGGER AS $$
BEGIN
IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN
RAISE EXCEPTION 'app_settings allows only one row';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings;
CREATE TRIGGER trg_singleton_app_settings
BEFORE INSERT ON app_settings
FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings();
`);
};
export const dropAppSettings = async (pool: Pool) => {
await pool.query(`
DROP TABLE IF EXISTS app_settings;
DROP FUNCTION IF EXISTS enforce_singleton_app_settings();
`);
};
@@ -1,127 +0,0 @@
import type { Pool } from "pg";
export const createContinuousAggregates = async (pool: Pool) => {
// Uptime & Response Time (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time,
MIN(response_time) AS min_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`);
// Uptime & Response Time (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = TRUE) AS up_checks,
COUNT(*) FILTER (WHERE status = FALSE) AS down_checks,
AVG(response_time) AS avg_response_time,
AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time,
AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time,
MAX(response_time) AS max_response_time
FROM checks
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('checks_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`);
// Infrastructure Metrics (Hourly)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 hour', created_at) AS bucket,
AVG(cpu_usage_percent) AS avg_cpu,
MAX(cpu_usage_percent) AS max_cpu,
AVG(mem_usage_percent) AS avg_memory,
MAX(mem_usage_percent) AS max_memory,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'hardware'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('hardware_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`);
// PageSpeed Scores (Daily)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily
WITH (timescaledb.continuous) AS
SELECT
monitor_id,
time_bucket('1 day', created_at) AS bucket,
AVG(lighthouse_performance) AS avg_performance,
AVG(lighthouse_accessibility) AS avg_accessibility,
AVG(lighthouse_best_practices) AS avg_best_practices,
AVG(lighthouse_seo) AS avg_seo,
COUNT(*) AS sample_count
FROM checks
WHERE monitor_type = 'pagespeed'
GROUP BY monitor_id, bucket
WITH NO DATA;
`);
await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`);
await pool.query(`
SELECT add_continuous_aggregate_policy('pagespeed_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
`);
await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`);
};
export const dropContinuousAggregates = async (pool: Pool) => {
await pool.query(`
DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE;
DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE;
`);
};
@@ -1,135 +0,0 @@
import type { Pool } from "pg";
export const createRetentionCompression = async (pool: Pool) => {
// -- Compression policies --
// Checks
await pool.query(`
ALTER TABLE checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Check child tables
await pool.query(`
ALTER TABLE check_disks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_network_interfaces SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE check_errors SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'check_id',
timescaledb.compress_orderby = 'check_created_at DESC'
);
SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE);
`);
// Geo checks
await pool.query(`
ALTER TABLE geo_checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'monitor_id',
timescaledb.compress_orderby = 'created_at DESC'
);
SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE);
`);
await pool.query(`
ALTER TABLE geo_check_results SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'geo_check_id',
timescaledb.compress_orderby = 'geo_check_created_at DESC'
);
SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE);
`);
// -- Retention policies (default 30 days) --
await pool.query(`
SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE);
SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE);
`);
// -- Dynamic retention helper function --
await pool.query(`
CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER)
RETURNS VOID AS $$
DECLARE
ttl_interval INTERVAL;
BEGIN
ttl_interval := (new_ttl_days || ' days')::INTERVAL;
PERFORM remove_retention_policy('checks', if_exists => true);
PERFORM remove_retention_policy('check_disks', if_exists => true);
PERFORM remove_retention_policy('check_network_interfaces', if_exists => true);
PERFORM remove_retention_policy('check_errors', if_exists => true);
PERFORM remove_retention_policy('geo_checks', if_exists => true);
PERFORM remove_retention_policy('geo_check_results', if_exists => true);
PERFORM add_retention_policy('checks', ttl_interval);
PERFORM add_retention_policy('check_disks', ttl_interval);
PERFORM add_retention_policy('check_network_interfaces', ttl_interval);
PERFORM add_retention_policy('check_errors', ttl_interval);
PERFORM add_retention_policy('geo_checks', ttl_interval);
PERFORM add_retention_policy('geo_check_results', ttl_interval);
END;
$$ LANGUAGE plpgsql;
`);
};
export const dropRetentionCompression = async (pool: Pool) => {
// Remove retention policies
await pool.query(`
SELECT remove_retention_policy('checks', if_exists => true);
SELECT remove_retention_policy('check_disks', if_exists => true);
SELECT remove_retention_policy('check_network_interfaces', if_exists => true);
SELECT remove_retention_policy('check_errors', if_exists => true);
SELECT remove_retention_policy('geo_checks', if_exists => true);
SELECT remove_retention_policy('geo_check_results', if_exists => true);
`);
// Remove compression policies
await pool.query(`
SELECT remove_compression_policy('checks', if_exists => true);
SELECT remove_compression_policy('check_disks', if_exists => true);
SELECT remove_compression_policy('check_network_interfaces', if_exists => true);
SELECT remove_compression_policy('check_errors', if_exists => true);
SELECT remove_compression_policy('geo_checks', if_exists => true);
SELECT remove_compression_policy('geo_check_results', if_exists => true);
`);
// Disable compression on tables
await pool.query(`
ALTER TABLE checks SET (timescaledb.compress = false);
ALTER TABLE check_disks SET (timescaledb.compress = false);
ALTER TABLE check_network_interfaces SET (timescaledb.compress = false);
ALTER TABLE check_errors SET (timescaledb.compress = false);
ALTER TABLE geo_checks SET (timescaledb.compress = false);
ALTER TABLE geo_check_results SET (timescaledb.compress = false);
`);
// Drop dynamic retention function
await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`);
};
@@ -1,150 +0,0 @@
import type { Pool } from "pg";
import type { ILogger } from "@/utils/logger.js";
import { createEnums, dropEnums } from "./0001_create_enums.js";
import { createTeams, dropTeams } from "./0002_create_teams.js";
import { createUsers, dropUsers } from "./0003_create_users.js";
import { createMonitors, dropMonitors } from "./0004_create_monitors.js";
import { createNotifications, dropNotifications } from "./0005_create_notifications.js";
import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js";
import { createIncidents, dropIncidents } from "./0007_create_incidents.js";
import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js";
import { createChecks, dropChecks } from "./0009_create_checks.js";
import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js";
import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js";
import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js";
import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js";
import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js";
import { createInvites, dropInvites } from "./0015_create_invites.js";
import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js";
import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js";
import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js";
import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js";
import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js";
import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js";
import { addStrategyToMonitors, dropStrategyFromMonitors } from "./0022_add_strategy_to_monitors.js";
const SERVICE_NAME = "TimescaleDB Migrations";
type MigrationEntry = {
name: string;
up: (pool: Pool) => Promise<void>;
down: (pool: Pool) => Promise<void>;
};
const migrations: MigrationEntry[] = [
{ name: "0001_create_enums", up: createEnums, down: dropEnums },
{ name: "0002_create_teams", up: createTeams, down: dropTeams },
{ name: "0003_create_users", up: createUsers, down: dropUsers },
{ name: "0004_create_monitors", up: createMonitors, down: dropMonitors },
{ name: "0005_create_notifications", up: createNotifications, down: dropNotifications },
{ name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications },
{ name: "0007_create_incidents", up: createIncidents, down: dropIncidents },
{ name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats },
{ name: "0009_create_checks", up: createChecks, down: dropChecks },
{ name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks },
{ name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces },
{ name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors },
{ name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks },
{ name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults },
{ name: "0015_create_invites", up: createInvites, down: dropInvites },
{ name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens },
{ name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows },
{ name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages },
{ name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings },
{ name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates },
{ name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression },
{ name: "0022_add_strategy_to_monitors", up: addStrategyToMonitors, down: dropStrategyFromMonitors },
];
const ensureMigrationsTable = async (pool: Pool) => {
await pool.query(`
DO $$ BEGIN
CREATE TYPE migration_status AS ENUM ('completed', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS migrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
status migration_status NOT NULL,
completed_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
};
const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => {
try {
logger?.info({ message: "Running migrations", service: SERVICE_NAME });
await ensureMigrationsTable(pool);
for (const migration of migrations) {
const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]);
if (result.rowCount !== null && result.rowCount > 0) {
logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME });
continue;
}
try {
await migration.up(pool);
await pool.query(
`INSERT INTO migrations (name, status, completed_at)
VALUES ($1, 'completed', NOW())
ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`,
[migration.name]
);
logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
await pool.query(
`INSERT INTO migrations (name, status, error)
VALUES ($1, 'failed', $2)
ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`,
[migration.name, err?.message]
);
throw error;
}
}
logger?.info({ message: "Migrations completed", service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => {
try {
await ensureMigrationsTable(pool);
// Find the last completed migration
const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1");
if (result.rowCount === 0) {
logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME });
return;
}
const lastMigrationName = result.rows[0].name;
const migration = migrations.find((m) => m.name === lastMigrationName);
if (!migration) {
throw new Error(`Migration "${lastMigrationName}" not found in migration list`);
}
logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME });
await migration.down(pool);
await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]);
logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
export { runTimescaleDBMigrations, rollbackTimescaleDBMigration };
+1
View File
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
systemEmailHost: { type: String },
systemEmailPort: { type: Number },
systemEmailAddress: { type: String },
systemEmailDisplayName: { type: String },
systemEmailPassword: { type: String },
systemEmailUser: { type: String },
systemEmailConnectionHost: { type: String, default: "localhost" },
+1
View File
@@ -271,6 +271,7 @@ const CheckSchema = new Schema<CheckDocument>(
{
timestamps: true,
strict: false,
suppressReservedKeysWarning: true,
timeseries: {
timeField: "createdAt",
metaField: "metadata",
+2 -2
View File
@@ -1,5 +1,5 @@
import { Schema, model, type Types } from "mongoose";
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
@@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
},
durationUnit: {
type: String,
enum: ["seconds", "minutes", "hours", "days"],
enum: DurationUnits,
},
repeat: {
type: Number,
+8 -1
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js";
import type {
CheckAudits,
CheckCaptureInfo,
@@ -355,6 +355,13 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Number,
default: 300000,
},
dnsServer: {
type: String,
},
dnsRecordType: {
type: String,
enum: DnsRecordTypes,
},
recentChecks: {
type: [checkSnapshotSchema],
default: [],
+1 -1
View File
@@ -16,7 +16,7 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
monitorId: {
type: Schema.Types.ObjectId,
ref: "Monitor",
index: true,
unique: true,
required: true,
},
avgResponseTime: {
+3 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
required: true,
},
notificationName: {
@@ -37,6 +37,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
homeserverUrl: { type: String },
roomId: { type: String },
accessToken: { type: String },
accountSid: { type: String },
twilioPhoneNumber: { type: String },
},
{
timestamps: true,
+17 -1
View File
@@ -1,6 +1,12 @@
import { Schema, model, type Types } from "mongoose";
import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js";
import { StatusPageTypes } from "@/types/statusPage.js";
import {
DEFAULT_STATUS_PAGE_THEME,
DEFAULT_STATUS_PAGE_THEME_MODE,
StatusPageThemeModes,
StatusPageThemes,
StatusPageTypes,
} from "@/types/statusPage.js";
type StatusPageDocumentBase = Omit<
StatusPage,
@@ -108,6 +114,16 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: String,
default: "",
},
theme: {
type: String,
enum: StatusPageThemes,
default: DEFAULT_STATUS_PAGE_THEME,
},
themeMode: {
type: String,
enum: StatusPageThemeModes,
default: DEFAULT_STATUS_PAGE_THEME_MODE,
},
},
{ timestamps: true }
);
File diff suppressed because it is too large. Load diff
@@ -1,274 +0,0 @@
import type { Pool } from "pg";
import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js";
import { getDateForRange } from "@/utils/dataUtils.js";
const dateStringToBucket = (dateString: string): string => {
if (dateString.includes("%M")) return "1 minute";
if (dateString.includes("%H")) return "1 hour";
return "1 day";
};
export class TimescaleGeoChecksRepository implements IGeoChecksRepository {
constructor(private pool: Pool) {}
createGeoChecks = async (geoChecks: Omit<GeoCheck, "id" | "__v" | "createdAt" | "updatedAt">[]): Promise<GeoCheck[]> => {
const created: GeoCheck[] = [];
for (const geoCheck of geoChecks) {
const geoResult = await this.pool.query(
`INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry)
VALUES ($1, $2, $3, $4)
RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`,
[geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null]
);
const row = geoResult.rows[0];
if (!row) continue;
for (const result of geoCheck.results) {
await this.pool.query(
`INSERT INTO geo_check_results (
geo_check_id, geo_check_created_at,
status, status_code,
location_continent, location_region, location_country, location_state, location_city,
location_longitude, location_latitude,
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`,
[
row.id,
row.created_at,
result.status,
result.statusCode,
result.location.continent,
result.location.region,
result.location.country,
result.location.state,
result.location.city,
result.location.longitude,
result.location.latitude,
result.timings.total,
result.timings.dns,
result.timings.tcp,
result.timings.tls,
result.timings.firstByte,
result.timings.download,
]
);
}
created.push(this.toGeoCheck(row, geoCheck.results));
}
return created;
};
findByMonitorId = async (
monitorId: string,
sortOrder: string,
dateRange: string,
page: number,
rowsPerPage: number,
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
const conditions: string[] = ["gc.monitor_id = $1"];
const values: unknown[] = [monitorId];
let paramIndex = 2;
const rangeDate = getDateForRange(dateRange);
if (rangeDate) {
conditions.push(`gc.created_at >= $${paramIndex++}`);
values.push(rangeDate);
}
if (continents && continents.length > 0) {
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
values.push(continents);
}
const where = conditions.join(" AND ");
const direction = sortOrder === "asc" ? "ASC" : "DESC";
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
const countValues = [...values];
const dataValues = [...values, rowsPerPage, offset];
const [countResult, dataResult] = await Promise.all([
this.pool.query(
`SELECT COUNT(*)::int AS count
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${where}`,
countValues
),
this.pool.query(
`SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type,
r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city,
r.location_longitude, r.location_latitude,
r.status, r.status_code,
r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download,
gc.created_at, gc.updated_at
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${where}
ORDER BY gc.created_at ${direction}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataValues
),
]);
const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({
id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`,
monitorId: row.monitor_id,
teamId: row.team_id,
type: row.monitor_type,
location: {
continent: row.location_continent,
region: row.location_region,
country: row.location_country,
state: row.location_state,
city: row.location_city,
longitude: row.location_longitude,
latitude: row.location_latitude,
},
status: row.status,
statusCode: row.status_code,
timings: {
total: row.timing_total ?? 0,
dns: row.timing_dns ?? 0,
tcp: row.timing_tcp ?? 0,
tls: row.timing_tls ?? 0,
firstByte: row.timing_first_byte ?? 0,
download: row.timing_download ?? 0,
},
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
}));
return { geoChecksCount: countResult.rows[0].count, geoChecks };
};
findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise<GeoCheck[]> => {
const geoRows = await this.pool.query(
`SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at
FROM geo_checks
WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3
ORDER BY created_at DESC`,
[monitorId, startDate, endDate]
);
const geoChecks: GeoCheck[] = [];
for (const row of geoRows.rows) {
const resultsRows = await this.pool.query(
`SELECT status, status_code,
location_continent, location_region, location_country, location_state, location_city,
location_longitude, location_latitude,
timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download
FROM geo_check_results
WHERE geo_check_id = $1`,
[row.id]
);
const results = resultsRows.rows.map(this.toGeoCheckResult);
geoChecks.push(this.toGeoCheck(row, results));
}
return geoChecks;
};
findGroupedByMonitorIdAndDateRange = async (
monitorId: string,
startDate: Date,
endDate: Date,
dateFormat: string,
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
const bucket = dateStringToBucket(dateFormat);
const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"];
const values: unknown[] = [bucket, monitorId, startDate, endDate];
let paramIndex = 5;
if (continents && continents.length > 0) {
conditions.push(`r.location_continent = ANY($${paramIndex++})`);
values.push(continents);
}
const result = await this.pool.query(
`SELECT
time_bucket($1::interval, gc.created_at) AS bucket_date,
r.location_continent AS continent,
ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime",
COUNT(*)::int AS "totalChecks",
ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage"
FROM geo_checks gc
JOIN geo_check_results r ON r.geo_check_id = gc.id
WHERE ${conditions.join(" AND ")}
GROUP BY bucket_date, r.location_continent
ORDER BY bucket_date, r.location_continent`,
values
);
return result.rows.map((row) => ({
bucketDate: (row.bucket_date as Date).toISOString(),
continent: row.continent as GeoContinent,
avgResponseTime: Number(row.avgResponseTime ?? 0),
totalChecks: Number(row.totalChecks ?? 0),
uptimePercentage: Number(row.uptimePercentage ?? 0),
}));
};
deleteByMonitorId = async (monitorId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]);
return result.rowCount ?? 0;
};
deleteByTeamId = async (teamId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM geo_checks`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private toGeoCheckResult = (row: Record<string, unknown>): GeoCheckResult => ({
location: {
continent: (row.location_continent as GeoContinent) ?? "",
region: (row.location_region as string) ?? "",
country: (row.location_country as string) ?? "",
state: (row.location_state as string) ?? "",
city: (row.location_city as string) ?? "",
longitude: (row.location_longitude as number) ?? 0,
latitude: (row.location_latitude as number) ?? 0,
},
status: (row.status as boolean) ?? false,
statusCode: (row.status_code as number) ?? 0,
timings: {
total: (row.timing_total as number) ?? 0,
dns: (row.timing_dns as number) ?? 0,
tcp: (row.timing_tcp as number) ?? 0,
tls: (row.timing_tls as number) ?? 0,
firstByte: (row.timing_first_byte as number) ?? 0,
download: (row.timing_download as number) ?? 0,
},
});
private toGeoCheck = (row: Record<string, unknown>, results: GeoCheckResult[]): GeoCheck => ({
id: row.id as string,
metadata: {
monitorId: row.monitor_id as string,
teamId: row.team_id as string,
type: row.monitor_type as MonitorType,
},
results,
expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(),
__v: 0,
createdAt: (row.created_at as Date).toISOString(),
updatedAt: (row.updated_at as Date).toISOString(),
});
}
@@ -1,294 +0,0 @@
import type { Pool } from "pg";
import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js";
import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js";
import { AppError } from "@/utils/AppError.js";
interface IncidentRow {
id: string;
monitor_id: string;
team_id: string;
start_time: Date;
end_time: Date | null;
status: boolean;
message: string | null;
status_code: number | null;
resolution_type: IncidentResolutionType;
resolved_by: string | null;
resolved_by_email: string | null;
comment: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code,
resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`;
export class TimescaleIncidentsRepository implements IIncidentsRepository {
constructor(private pool: Pool) {}
create = async (incident: Partial<Incident>): Promise<Incident> => {
const result = await this.pool.query<IncidentRow>(
`INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING ${COLUMNS}`,
[
incident.monitorId,
incident.teamId,
incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(),
incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null,
incident.status ?? true,
incident.message ?? null,
incident.statusCode ?? null,
incident.resolutionType ?? null,
incident.resolvedBy ?? null,
incident.resolvedByEmail ?? null,
incident.comment ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create incident", status: 500 });
}
return this.toEntity(row);
};
findById = async (incidentId: string, teamId: string): Promise<Incident> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 });
}
return this.toEntity(row);
};
findActiveByIncidentId = async (incidentId: string, teamId: string): Promise<Incident | null> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [
incidentId,
teamId,
]);
const row = result.rows[0];
return row ? this.toEntity(row) : null;
};
findActiveByMonitorId = async (monitorId: string, teamId: string): Promise<Incident | null> => {
const result = await this.pool.query<IncidentRow>(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [
monitorId,
teamId,
]);
const row = result.rows[0];
return row ? this.toEntity(row) : null;
};
findByTeamId = async (
teamId: string,
startDate: Date | undefined,
page: number,
rowsPerPage: number,
sortOrder?: string,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<Incident[]> => {
const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
const direction = sortOrder === "asc" ? "ASC" : "DESC";
const offset = page * rowsPerPage;
const result = await this.pool.query<IncidentRow>(
`SELECT ${COLUMNS} FROM incidents
WHERE ${conditions.join(" AND ")}
ORDER BY created_at ${direction}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, rowsPerPage, offset]
);
return result.rows.map(this.toEntity);
};
countByTeamId = async (
teamId: string,
startDate: Date | undefined,
status?: boolean,
monitorId?: string,
resolutionType?: string
): Promise<number> => {
const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType);
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values);
return result.rows[0].count;
};
findSummaryByTeamId = async (teamId: string, limit?: number): Promise<IncidentSummary> => {
// Counts by status and resolution type
const countsResult = await this.pool.query(
`SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status = TRUE)::int AS active,
COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual,
COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic
FROM incidents WHERE team_id = $1`,
[teamId]
);
const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 };
// Average resolution time
const resTimeResult = await this.pool.query(
`SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds
FROM incidents
WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`,
[teamId]
);
const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0;
const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100;
// Top monitor by incident count
const topMonitorResult = await this.pool.query(
`SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count
FROM incidents i
LEFT JOIN monitors m ON m.id = i.monitor_id
WHERE i.team_id = $1
GROUP BY i.monitor_id, m.name
ORDER BY count DESC
LIMIT 1`,
[teamId]
);
// Latest incidents
const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10);
const latestResult = await this.pool.query(
`SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time,
i.resolution_type, i.message, i.status_code, i.created_at
FROM incidents i
LEFT JOIN monitors m ON m.id = i.monitor_id
WHERE i.team_id = $1
ORDER BY i.created_at DESC
LIMIT $2`,
[teamId, latestLimit]
);
const topRow = topMonitorResult.rows[0];
return {
total: counts.total,
totalActive: counts.active,
totalManualResolutions: counts.manual,
totalAutomaticResolutions: counts.automatic,
avgResolutionTimeHours,
topMonitor: topRow
? {
monitorId: topRow.monitor_id,
monitorName: topRow.monitor_name ?? null,
incidentCount: topRow.count,
}
: null,
latestIncidents: latestResult.rows.map((row) => ({
id: row.id,
monitorId: row.monitor_id,
monitorName: row.monitor_name ?? null,
status: row.status,
startTime: row.start_time.toISOString(),
endTime: row.end_time ? row.end_time.toISOString() : null,
resolutionType: row.resolution_type ?? null,
message: row.message ?? null,
statusCode: row.status_code ?? null,
createdAt: row.created_at.toISOString(),
})),
};
};
updateById = async (incidentId: string, teamId: string, patch: Partial<Incident>): Promise<Incident> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof Incident, string][] = [
["status", "status"],
["message", "message"],
["statusCode", "status_code"],
["endTime", "end_time"],
["resolutionType", "resolution_type"],
["resolvedBy", "resolved_by"],
["resolvedByEmail", "resolved_by_email"],
["comment", "comment"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key];
sets.push(`${column} = $${paramIndex++}`);
values.push(value);
}
}
if (sets.length === 0) {
return this.findById(incidentId, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(incidentId, teamId);
const result = await this.pool.query<IncidentRow>(
`UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 });
}
return this.toEntity(row);
};
deleteByMonitorId = async (monitorId: string, teamId: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM incidents`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
let paramIndex = 2;
if (startDate) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(startDate);
}
if (status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
values.push(status);
}
if (monitorId) {
conditions.push(`monitor_id = $${paramIndex++}`);
values.push(monitorId);
}
if (resolutionType) {
conditions.push(`resolution_type = $${paramIndex++}`);
values.push(resolutionType);
}
return { conditions, values, paramIndex };
};
private toEntity = (row: IncidentRow): Incident => ({
id: row.id,
monitorId: row.monitor_id,
teamId: row.team_id,
startTime: row.start_time.toISOString(),
endTime: row.end_time ? row.end_time.toISOString() : null,
status: row.status,
message: row.message ?? null,
statusCode: row.status_code ?? null,
resolutionType: row.resolution_type ?? null,
resolvedBy: row.resolved_by ?? null,
resolvedByEmail: row.resolved_by_email ?? null,
comment: row.comment ?? null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
-13
View File
@@ -1,54 +1,41 @@
export * from "@/repositories/monitors/IMonitorsRepository.js";
export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js";
export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js";
export * from "@/repositories/checks/IChecksRepository.js";
export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js";
export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js";
export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js";
export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js";
export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js";
export * from "@/repositories/status-pages/IStatusPagesRepository.js";
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js";
export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js";
export * from "@/repositories/users/IUsersRepository.js";
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js";
export * from "@/repositories/invites/IInvitesRepository.js";
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js";
export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js";
export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js";
export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js";
export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js";
export * from "@/repositories/settings/ISettingsRepository.js";
export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js";
export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js";
export * from "@/repositories/notifications/INotificationsRepository.js";
export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js";
export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js";
export * from "@/repositories/incidents/IIncidentsRepository.js";
export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js";
export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js";
export * from "@/repositories/teams/ITeamsRepository.js";
export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js";
export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js";
export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js";
export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js";
export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js";
export * from "@/repositories/geo-checks/IGeoChecksRepository.js";
export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js";
export * from "@/repositories/dlq/IDLQRepository.js";
export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js";
export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js";
@@ -1,77 +0,0 @@
import type { Pool } from "pg";
import crypto from "crypto";
import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js";
import type { Invite } from "@/types/invite.js";
import type { UserRole } from "@/types/user.js";
import { AppError } from "@/utils/AppError.js";
const parsePostgresArray = (value: unknown): UserRole[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as UserRole[];
};
interface InviteRow {
id: string;
email: string;
team_id: string;
roles: UserRole[];
token: string;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`;
export class TimescaleInvitesRepository implements IInvitesRepository {
constructor(private pool: Pool) {}
create = async (invite: Partial<Invite>): Promise<Invite> => {
// Delete existing invites for this email
await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]);
const token = crypto.randomBytes(32).toString("hex");
const result = await this.pool.query<InviteRow>(
`INSERT INTO invites (email, team_id, roles, token, expiry)
VALUES ($1, $2, $3, $4, $5)
RETURNING ${COLUMNS}`,
[invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create invite", status: 500 });
}
return this.toEntity(row);
};
findByToken = async (token: string): Promise<Invite> => {
const result = await this.pool.query<InviteRow>(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(row);
};
findByTokenAndDelete = async (token: string): Promise<Invite> => {
const result = await this.pool.query<InviteRow>(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(row);
};
private toEntity = (row: InviteRow): Invite => ({
id: row.id,
email: row.email,
teamId: row.team_id,
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
token: row.token,
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,197 +0,0 @@
import type { Pool } from "pg";
import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js";
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
import { AppError } from "@/utils/AppError.js";
interface MaintenanceWindowRow {
id: string;
monitor_id: string;
team_id: string;
active: boolean;
name: string;
duration: number;
duration_unit: DurationUnit;
repeat: number;
start_time: Date;
end_time: Date;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat,
start_time, end_time, expiry, created_at, updated_at`;
export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository {
constructor(private pool: Pool) {}
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const startTime = data.start ? new Date(data.start) : null;
const endTime = data.end ? new Date(data.end) : null;
// One-time windows expire at end time
const expiry = data.repeat === 0 ? endTime : null;
const result = await this.pool.query<MaintenanceWindowRow>(
`INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING ${COLUMNS}`,
[
data.monitorId,
data.teamId,
data.active ?? true,
data.name ?? null,
data.duration ?? null,
data.durationUnit ?? null,
data.repeat ?? 0,
startTime,
endTime,
expiry,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create maintenance window", status: 500 });
}
return this.toEntity(row);
};
findById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [
id,
teamId,
]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance Window not found", status: 404 });
}
return this.toEntity(row);
};
findByMonitorId = async (monitorId: string, teamId: string): Promise<MaintenanceWindow[]> => {
const result = await this.pool.query<MaintenanceWindowRow>(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [
monitorId,
teamId,
]);
return result.rows.map(this.toEntity);
};
findByTeamId = async (
teamId: string,
page: number,
rowsPerPage: number,
field?: string,
order?: string,
active?: boolean
): Promise<MaintenanceWindow[]> => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
let paramIndex = 2;
if (active !== undefined) {
conditions.push(`active = $${paramIndex++}`);
values.push(active);
}
const fieldMap: Record<string, string> = {
createdAt: "created_at",
name: "name",
start: "start_time",
end: "end_time",
active: "active",
};
const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at";
const sortDirection = order === "asc" ? "ASC" : "DESC";
const offset = page && rowsPerPage ? page * rowsPerPage : 0;
const result = await this.pool.query<MaintenanceWindowRow>(
`SELECT ${COLUMNS} FROM maintenance_windows
WHERE ${conditions.join(" AND ")}
ORDER BY ${sortColumn} ${sortDirection}
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
[...values, rowsPerPage, offset]
);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, teamId: string, patch: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [
["active", "active", false],
["name", "name", false],
["duration", "duration", false],
["durationUnit", "duration_unit", false],
["repeat", "repeat", false],
["start", "start_time", true],
["end", "end_time", true],
];
for (const [key, column, isDate] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(isDate ? new Date(patch[key] as string) : patch[key]);
}
}
if (sets.length === 0) {
return this.findById(id, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<MaintenanceWindowRow>(
`UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string, teamId: string): Promise<MaintenanceWindow> => {
const result = await this.pool.query<MaintenanceWindowRow>(
`DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`,
[id, teamId]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 });
}
return this.toEntity(row);
};
countByTeamId = async (teamId: string, active?: boolean): Promise<number> => {
const conditions: string[] = ["team_id = $1"];
const values: unknown[] = [teamId];
if (active !== undefined) {
conditions.push(`active = $2`);
values.push(active);
}
const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values);
return result.rows[0].count;
};
private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({
id: row.id,
monitorId: row.monitor_id,
teamId: row.team_id,
active: row.active,
name: row.name,
duration: row.duration,
durationUnit: row.duration_unit,
repeat: row.repeat,
start: row.start_time.toISOString(),
end: row.end_time.toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,11 +1,12 @@
import type { MonitorStats } from "@/types/index.js";
import type { MonitorStats, CheckResultInput } from "@/types/index.js";
export interface IMonitorStatsRepository {
// create
create(data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// single fetch
findByMonitorId(monitorId: string): Promise<MonitorStats>;
// update
updateByMonitorId(monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats>;
// Upsert must be atomic to prevent race conditions
updateByMonitorId(monitorId: string, result: CheckResultInput): Promise<MonitorStats>;
// delete
deleteByMonitorId(monitorId: string): Promise<MonitorStats>;
deleteByMonitorIds(monitorIds: string[]): Promise<number>;
@@ -1,5 +1,5 @@
import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js";
import type { MonitorStats } from "@/types/index.js";
import type { MonitorStats, CheckResultInput } from "@/types/index.js";
import { IMonitorStatsRepository } from "@/repositories/index.js";
import mongoose from "mongoose";
import { AppError } from "@/utils/AppError.js";
@@ -46,10 +46,48 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository {
return this.toEntity(monitorStats);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true });
updateByMonitorId = async (monitorId: string, result: CheckResultInput): Promise<MonitorStats> => {
const { status, responseTime, now } = result;
const objectId = new mongoose.Types.ObjectId(monitorId);
const upDelta = status ? 1 : 0;
const downDelta = status ? 0 : 1;
const pipeline = [
{
$set: {
monitorId: objectId,
totalChecks: { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] },
totalUpChecks: { $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] },
totalDownChecks: { $add: [{ $ifNull: ["$totalDownChecks", 0] }, downDelta] },
avgResponseTime: {
$divide: [
{
$add: [{ $multiply: [{ $ifNull: ["$avgResponseTime", 0] }, { $ifNull: ["$totalChecks", 0] }] }, responseTime],
},
{ $add: [{ $ifNull: ["$totalChecks", 0] }, 1] },
],
},
uptimePercentage: {
$divide: [{ $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] }, { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] }],
},
maxResponseTime: { $max: [{ $ifNull: ["$maxResponseTime", 0] }, responseTime] },
lastResponseTime: responseTime,
lastCheckTimestamp: now,
timeOfLastFailure: status
? {
$cond: [{ $eq: [{ $ifNull: ["$timeOfLastFailure", 0] }, 0] }, now, "$timeOfLastFailure"],
}
: 0,
},
},
];
const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: objectId }, pipeline, {
upsert: true,
new: true,
setDefaultsOnInsert: true,
});
if (!updated) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
throw new AppError({ message: "Failed to apply check result to monitor stats", status: 500 });
}
return this.toEntity(updated);
};
@@ -1,140 +0,0 @@
import type { Pool } from "pg";
import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js";
import type { MonitorStats } from "@/types/monitorStats.js";
import { AppError } from "@/utils/AppError.js";
interface MonitorStatsRow {
id: string;
monitor_id: string;
avg_response_time: number;
max_response_time: number;
total_checks: number;
total_up_checks: number;
total_down_checks: number;
uptime_percentage: number;
last_check_timestamp: Date | null;
last_response_time: number;
time_of_last_failure: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure,
created_at, updated_at`;
export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository {
constructor(private pool: Pool) {}
create = async (data: Omit<MonitorStats, "id" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(
`INSERT INTO monitor_stats (monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks,
total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING ${COLUMNS}`,
[
data.monitorId,
data.avgResponseTime,
data.maxResponseTime,
data.totalChecks,
data.totalUpChecks,
data.totalDownChecks,
data.uptimePercentage,
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
data.lastResponseTime,
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create monitor stats", status: 500 });
}
return this.toEntity(row);
};
findByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(`SELECT ${COLUMNS} FROM monitor_stats WHERE monitor_id = $1`, [monitorId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
updateByMonitorId = async (monitorId: string, data: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(
`UPDATE monitor_stats SET
avg_response_time = $2,
max_response_time = $3,
total_checks = $4,
total_up_checks = $5,
total_down_checks = $6,
uptime_percentage = $7,
last_check_timestamp = $8,
last_response_time = $9,
time_of_last_failure = $10,
updated_at = NOW()
WHERE monitor_id = $1
RETURNING ${COLUMNS}`,
[
monitorId,
data.avgResponseTime,
data.maxResponseTime,
data.totalChecks,
data.totalUpChecks,
data.totalDownChecks,
data.uptimePercentage,
data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null,
data.lastResponseTime,
data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
deleteByMonitorId = async (monitorId: string): Promise<MonitorStats> => {
const result = await this.pool.query<MonitorStatsRow>(`DELETE FROM monitor_stats WHERE monitor_id = $1 RETURNING ${COLUMNS}`, [monitorId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Monitor stats not found", status: 404 });
}
return this.toEntity(row);
};
deleteByMonitorIds = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
return 0;
}
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = ANY($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise<number> => {
if (!monitorIds.length) {
const result = await this.pool.query(`DELETE FROM monitor_stats`);
return result.rowCount ?? 0;
}
const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id != ALL($1)`, [monitorIds]);
return result.rowCount ?? 0;
};
private toEntity = (row: MonitorStatsRow): MonitorStats => ({
id: row.id,
monitorId: row.monitor_id,
avgResponseTime: row.avg_response_time,
maxResponseTime: row.max_response_time,
totalChecks: Number(row.total_checks),
totalUpChecks: Number(row.total_up_checks),
totalDownChecks: Number(row.total_down_checks),
uptimePercentage: row.uptime_percentage,
lastCheckTimestamp: row.last_check_timestamp ? row.last_check_timestamp.getTime() : 0,
lastResponseTime: row.last_response_time,
timeOfLastFailure: row.time_of_last_failure ? row.time_of_last_failure.getTime() : undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,4 +1,4 @@
import { type MonitorType, type Monitor, type MonitorsSummary } from "@/types/index.js";
import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js";
export interface TeamQueryConfig {
limit?: number;
@@ -29,7 +29,17 @@ export interface IMonitorsRepository {
// update
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
updateStatusWindowAndChecks(
monitorId: string,
teamId: string,
status: boolean,
checkSnapshot: CheckSnapshot,
windowSize: number,
maxRecentChecks: number,
statusPatch?: Partial<Monitor>
): Promise<Monitor>;
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
// delete
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
@@ -182,6 +182,33 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(updatedMonitor);
};
updateStatusWindowAndChecks = async (
monitorId: string,
teamId: string,
status: boolean,
checkSnapshot: CheckSnapshot,
windowSize: number,
maxRecentChecks: number,
statusPatch?: Partial<Monitor>
): Promise<Monitor> => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
{
$push: {
statusWindow: { $each: [status], $slice: -windowSize },
recentChecks: { $each: [checkSnapshot], $slice: -maxRecentChecks },
},
...(statusPatch && { $set: statusPatch }),
},
{ returnDocument: "after" }
);
if (!updatedMonitor) {
throw new AppError({ message: `Failed to update status and checks for monitor with id ${monitorId}`, status: 500 });
}
return this.toEntity(updatedMonitor);
};
togglePauseById = async (monitorId: string, teamId: string) => {
const monitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
@@ -207,6 +234,34 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(monitor);
};
bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const filter = {
_id: { $in: objectIds },
teamId: new mongoose.Types.ObjectId(teamId),
isActive: pause, // Only pause if active (true), only resume if inactive (false)
};
const nextStatus = pause ? "paused" : "initializing";
const eligible = await MonitorModel.find(filter);
if (eligible.length === 0) return [];
const now = new Date();
await MonitorModel.updateMany(
{ _id: { $in: eligible.map((doc) => doc._id) } },
{ $set: { isActive: !pause, status: nextStatus, updatedAt: now } },
{ timestamps: false }
);
eligible.forEach((doc) => {
doc.isActive = !pause;
doc.status = nextStatus;
doc.updatedAt = now;
});
return this.mapDocuments(eligible);
};
deleteById = async (monitorId: string, teamId: string) => {
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
@@ -392,6 +447,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -452,6 +509,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
dnsServer: doc.dnsServer ?? undefined,
dnsRecordType: doc.dnsRecordType ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
File diff suppressed because it is too large. Load diff
@@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository {
homeserverUrl: doc.homeserverUrl ?? undefined,
roomId: doc.roomId ?? undefined,
accessToken: doc.accessToken ?? undefined,
accountSid: doc.accountSid ?? undefined,
twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -1,140 +0,0 @@
import type { Pool } from "pg";
import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js";
import type { Notification, NotificationChannel } from "@/types/notification.js";
import { AppError } from "@/utils/AppError.js";
interface NotificationRow {
id: string;
user_id: string;
team_id: string;
type: NotificationChannel;
notification_name: string;
address: string | null;
phone: string | null;
homeserver_url: string | null;
room_id: string | null;
access_token: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone,
homeserver_url, room_id, access_token, created_at, updated_at`;
export class TimescaleNotificationsRepository implements INotificationsRepository {
constructor(private pool: Pool) {}
create = async (data: Partial<Notification>): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(
`INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING ${COLUMNS}`,
[
data.userId,
data.teamId,
data.type,
data.notificationName,
data.address ?? null,
data.phone ?? null,
data.homeserverUrl ?? null,
data.roomId ?? null,
data.accessToken ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create notification", status: 500 });
}
return this.toEntity(row);
};
findById = async (id: string, teamId: string): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = $1 AND team_id = $2`, [id, teamId]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found", status: 404 });
}
return this.toEntity(row);
};
findNotificationsByIds = async (ids: string[]): Promise<Notification[]> => {
if (!ids.length) {
return [];
}
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]);
return result.rows.map(this.toEntity);
};
findByTeamId = async (teamId: string): Promise<Notification[]> => {
const result = await this.pool.query<NotificationRow>(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, teamId: string, patch: Partial<Notification>): Promise<Notification> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof Notification, string][] = [
["type", "type"],
["notificationName", "notification_name"],
["address", "address"],
["phone", "phone"],
["homeserverUrl", "homeserver_url"],
["roomId", "room_id"],
["accessToken", "access_token"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(patch[key]);
}
}
if (sets.length === 0) {
return this.findById(id, teamId);
}
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<NotificationRow>(
`UPDATE notifications SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found or could not be updated", status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string, teamId: string): Promise<Notification> => {
const result = await this.pool.query<NotificationRow>(`DELETE FROM notifications WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, [
id,
teamId,
]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Notification not found or could not be deleted", status: 404 });
}
return this.toEntity(row);
};
private toEntity = (row: NotificationRow): Notification => ({
id: row.id,
userId: row.user_id,
teamId: row.team_id,
type: row.type,
notificationName: row.notification_name,
address: row.address ?? undefined,
phone: row.phone ?? undefined,
homeserverUrl: row.homeserver_url ?? undefined,
roomId: row.room_id ?? undefined,
accessToken: row.access_token ?? undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,59 +0,0 @@
import type { Pool } from "pg";
import crypto from "crypto";
import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js";
import type { RecoveryToken } from "@/types/recoveryToken.js";
import { AppError } from "@/utils/AppError.js";
interface RecoveryTokenRow {
id: string;
email: string;
token: string;
expiry: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, email, token, expiry, created_at, updated_at`;
export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository {
constructor(private pool: Pool) {}
create = async (email: string): Promise<RecoveryToken> => {
const token = crypto.randomBytes(32).toString("hex");
const result = await this.pool.query<RecoveryTokenRow>(
`INSERT INTO recovery_tokens (email, token)
VALUES ($1, $2)
ON CONFLICT (email) DO UPDATE SET token = $2, updated_at = NOW()
RETURNING ${COLUMNS}`,
[email, token]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create recovery token", status: 500 });
}
return this.toEntity(row);
};
findByToken = async (token: string): Promise<RecoveryToken> => {
const result = await this.pool.query<RecoveryTokenRow>(`SELECT ${COLUMNS} FROM recovery_tokens WHERE token = $1`, [token]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Recovery token not found", status: 404 });
}
return this.toEntity(row);
};
deleteManyByEmail = async (email: string): Promise<number> => {
const result = await this.pool.query(`DELETE FROM recovery_tokens WHERE email = $1`, [email]);
return result.rowCount ?? 0;
};
private toEntity = (row: RecoveryTokenRow): RecoveryToken => ({
id: row.id,
email: row.email,
token: row.token,
expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,217 +0,0 @@
import type { Pool } from "pg";
import type { ISettingsRepository } from "./ISettingsRepository.js";
import type { Settings, SettingsUpdate } from "@/types/settings.js";
interface SettingsRow {
id: string;
check_ttl: number;
language: string | null;
jwt_secret: string | null;
pagespeed_api_key: string | null;
system_email_host: string | null;
system_email_port: number | null;
system_email_address: string | null;
system_email_password: string | null;
system_email_user: string | null;
system_email_connection_host: string | null;
system_email_tls_servername: string | null;
system_email_secure: boolean | null;
system_email_pool: boolean | null;
system_email_ignore_tls: boolean | null;
system_email_require_tls: boolean | null;
system_email_reject_unauthorized: boolean | null;
show_url: boolean | null;
version: string | null;
threshold_cpu_usage: number | null;
threshold_memory_usage: number | null;
threshold_disk_usage: number | null;
threshold_temperature: number | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature,
created_at, updated_at`;
export class TimescaleSettingsRepository implements ISettingsRepository {
constructor(private pool: Pool) {}
create = async (settings: Partial<Settings>): Promise<Settings> => {
const result = await this.pool.query<SettingsRow>(
`INSERT INTO app_settings (check_ttl, language, jwt_secret, pagespeed_api_key,
system_email_host, system_email_port, system_email_address, system_email_password, system_email_user,
system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool,
system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized,
show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING ${COLUMNS}`,
[
settings.checkTTL ?? 30,
settings.language ?? null,
settings.jwtSecret ?? null,
settings.pagespeedApiKey ?? null,
settings.systemEmailHost ?? null,
settings.systemEmailPort ?? null,
settings.systemEmailAddress ?? null,
settings.systemEmailPassword ?? null,
settings.systemEmailUser ?? null,
settings.systemEmailConnectionHost ?? null,
settings.systemEmailTLSServername ?? null,
settings.systemEmailSecure ?? false,
settings.systemEmailPool ?? false,
settings.systemEmailIgnoreTLS ?? false,
settings.systemEmailRequireTLS ?? false,
settings.systemEmailRejectUnauthorized ?? true,
settings.showURL ?? false,
settings.version ?? 1,
settings.globalThresholds?.cpu ?? null,
settings.globalThresholds?.memory ?? null,
settings.globalThresholds?.disk ?? null,
settings.globalThresholds?.temperature ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new Error("Failed to create app settings");
}
return this.toEntity(row);
};
findSingleton = async (): Promise<Settings | null> => {
const result = await this.pool.query<SettingsRow>(`SELECT ${COLUMNS} FROM app_settings LIMIT 1`);
const row = result.rows[0];
if (!row) {
return null;
}
return this.toEntity(row);
};
update = async (settings: SettingsUpdate): Promise<Settings> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof SettingsUpdate, string][] = [
["checkTTL", "check_ttl"],
["language", "language"],
["jwtSecret", "jwt_secret"],
["pagespeedApiKey", "pagespeed_api_key"],
["systemEmailHost", "system_email_host"],
["systemEmailPort", "system_email_port"],
["systemEmailAddress", "system_email_address"],
["systemEmailPassword", "system_email_password"],
["systemEmailUser", "system_email_user"],
["systemEmailConnectionHost", "system_email_connection_host"],
["systemEmailTLSServername", "system_email_tls_servername"],
["systemEmailSecure", "system_email_secure"],
["systemEmailPool", "system_email_pool"],
["systemEmailIgnoreTLS", "system_email_ignore_tls"],
["systemEmailRequireTLS", "system_email_require_tls"],
["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"],
["showURL", "show_url"],
["version", "version"],
];
for (const [key, column] of fieldMap) {
if (key in settings) {
sets.push(`${column} = $${paramIndex++}`);
// null means unset the field
values.push(settings[key] ?? null);
}
}
// Handle globalThresholds
if ("globalThresholds" in settings) {
const thresholds = settings.globalThresholds;
if (thresholds === null || thresholds === undefined) {
sets.push(`threshold_cpu_usage = NULL`);
sets.push(`threshold_memory_usage = NULL`);
sets.push(`threshold_disk_usage = NULL`);
sets.push(`threshold_temperature = NULL`);
} else {
if ("cpu" in thresholds) {
sets.push(`threshold_cpu_usage = $${paramIndex++}`);
values.push(thresholds.cpu ?? null);
}
if ("memory" in thresholds) {
sets.push(`threshold_memory_usage = $${paramIndex++}`);
values.push(thresholds.memory ?? null);
}
if ("disk" in thresholds) {
sets.push(`threshold_disk_usage = $${paramIndex++}`);
values.push(thresholds.disk ?? null);
}
if ("temperature" in thresholds) {
sets.push(`threshold_temperature = $${paramIndex++}`);
values.push(thresholds.temperature ?? null);
}
}
}
if (sets.length === 0) {
const existing = await this.findSingleton();
if (!existing) {
throw new Error("App settings not found");
}
return existing;
}
sets.push(`updated_at = NOW()`);
// Upsert: update the single row, or insert if none exists
const result = await this.pool.query<SettingsRow>(`UPDATE app_settings SET ${sets.join(", ")} RETURNING ${COLUMNS}`, values);
const row = result.rows[0];
if (!row) {
// No row existed, create one
return this.create(settings as Partial<Settings>);
}
return this.toEntity(row);
};
deleteLegacy = async (): Promise<boolean> => {
// No legacy rows in TimescaleDB — singleton is enforced by trigger
const result = await this.pool.query(`DELETE FROM app_settings WHERE version IS NULL`);
return (result.rowCount ?? 0) > 0;
};
private toEntity = (row: SettingsRow): Settings => ({
id: row.id,
checkTTL: row.check_ttl,
language: row.language ?? "",
jwtSecret: row.jwt_secret ?? undefined,
pagespeedApiKey: row.pagespeed_api_key ?? undefined,
systemEmailHost: row.system_email_host ?? undefined,
systemEmailPort: row.system_email_port ?? undefined,
systemEmailAddress: row.system_email_address ?? undefined,
systemEmailPassword: row.system_email_password ?? undefined,
systemEmailUser: row.system_email_user ?? undefined,
systemEmailConnectionHost: row.system_email_connection_host ?? undefined,
systemEmailTLSServername: row.system_email_tls_servername ?? undefined,
systemEmailSecure: row.system_email_secure ?? false,
systemEmailPool: row.system_email_pool ?? false,
systemEmailIgnoreTLS: row.system_email_ignore_tls ?? false,
systemEmailRequireTLS: row.system_email_require_tls ?? false,
systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true,
showURL: row.show_url ?? false,
singleton: true,
version: Number(row.version ?? 1),
globalThresholds:
row.threshold_cpu_usage !== null ||
row.threshold_memory_usage !== null ||
row.threshold_disk_usage !== null ||
row.threshold_temperature !== null
? {
cpu: row.threshold_cpu_usage ?? undefined,
memory: row.threshold_memory_usage ?? undefined,
disk: row.threshold_disk_usage ?? undefined,
temperature: row.threshold_temperature ?? undefined,
}
: undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -59,6 +59,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS,
theme: doc.theme,
themeMode: doc.themeMode,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
@@ -1,260 +0,0 @@
import type { Pool } from "pg";
import type { IStatusPagesRepository } from "./IStatusPagesRepository.js";
import type { StatusPage, StatusPageType } from "@/types/statusPage.js";
import { AppError } from "@/utils/AppError.js";
const parsePostgresArray = (value: unknown): StatusPageType[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as StatusPageType[];
};
interface StatusPageRow {
id: string;
user_id: string;
team_id: string;
types: StatusPageType[];
company_name: string;
url: string;
timezone: string | null;
color: string;
logo_data: Buffer | null;
logo_content_type: string | null;
is_published: boolean;
show_charts: boolean;
show_uptime_percentage: boolean;
show_admin_login_link: boolean;
show_infrastructure: boolean;
custom_css: string | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`;
export class TimescaleStatusPagesRepository implements IStatusPagesRepository {
constructor(private pool: Pool) {}
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const result = await this.pool.query<StatusPageRow>(
`INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color,
logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage,
show_admin_login_link, show_infrastructure, custom_css)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING ${COLUMNS}`,
[
userId,
teamId,
data.type ?? ["uptime"],
data.companyName ?? "",
data.url ?? "",
data.timezone ?? null,
data.color ?? "#4169E1",
image?.buffer ?? null,
image?.mimetype ?? null,
data.isPublished ?? false,
data.showCharts ?? false,
data.showUptimePercentage ?? false,
data.showAdminLoginLink ?? false,
data.showInfrastructure ?? false,
data.customCSS ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create status page", status: 500 });
}
// Insert monitor associations
if (data.monitors?.length) {
for (const [i, monitorId] of data.monitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[row.id, monitorId, i]
);
}
}
if (data.subMonitors?.length) {
for (const [i, monitorId] of data.subMonitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[row.id, monitorId, i]
);
}
}
return this.loadEntity(row);
};
findByUrl = async (url: string): Promise<StatusPage> => {
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE url = $1`, [url]);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.loadEntity(row);
};
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
const result = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE team_id = $1`, [teamId]);
const pages: StatusPage[] = [];
for (const row of result.rows) {
pages.push(await this.loadEntity(row));
}
return pages;
};
updateById = async (
id: string,
teamId: string,
image: Express.Multer.File | undefined,
patch: Partial<StatusPage> & { removeLogo?: string }
): Promise<StatusPage> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
const fieldMap: [keyof StatusPage, string][] = [
["type", "types"],
["companyName", "company_name"],
["url", "url"],
["timezone", "timezone"],
["color", "color"],
["isPublished", "is_published"],
["showCharts", "show_charts"],
["showUptimePercentage", "show_uptime_percentage"],
["showAdminLoginLink", "show_admin_login_link"],
["showInfrastructure", "show_infrastructure"],
["customCSS", "custom_css"],
];
for (const [key, column] of fieldMap) {
if (patch[key] !== undefined) {
sets.push(`${column} = $${paramIndex++}`);
values.push(patch[key]);
}
}
if (image) {
sets.push(`logo_data = $${paramIndex++}`);
values.push(image.buffer);
sets.push(`logo_content_type = $${paramIndex++}`);
values.push(image.mimetype);
} else if (patch.removeLogo === "true") {
sets.push(`logo_data = NULL`);
sets.push(`logo_content_type = NULL`);
}
if (sets.length > 0) {
sets.push(`updated_at = NOW()`);
values.push(id, teamId);
const result = await this.pool.query<StatusPageRow>(
`UPDATE status_pages SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex}
RETURNING ${COLUMNS}`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
}
// Update monitor associations if provided
if (patch.monitors !== undefined) {
await this.pool.query(`DELETE FROM status_page_monitors WHERE status_page_id = $1`, [id]);
for (const [i, monitorId] of patch.monitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[id, monitorId, i]
);
}
}
if (patch.subMonitors !== undefined) {
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE status_page_id = $1`, [id]);
for (const [i, monitorId] of patch.subMonitors.entries()) {
await this.pool.query(
`INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[id, monitorId, i]
);
}
}
// Re-fetch to return full entity
const freshResult = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
const freshRow = freshResult.rows[0];
if (!freshRow) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return this.loadEntity(freshRow);
};
deleteById = async (id: string, teamId: string): Promise<StatusPage> => {
// Load entity before delete (join tables cascade)
const existing = await this.pool.query<StatusPageRow>(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
const row = existing.rows[0];
if (!row) {
throw new AppError({ message: "Status page not found", status: 404 });
}
const entity = await this.loadEntity(row);
await this.pool.query(`DELETE FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]);
return entity;
};
removeMonitorFromStatusPages = async (monitorId: string): Promise<number> => {
// Return count of distinct status pages affected, matching Mongo's modifiedCount
const result = await this.pool.query(
`SELECT COUNT(DISTINCT status_page_id)::int AS count FROM (
SELECT status_page_id FROM status_page_monitors WHERE monitor_id = $1
UNION
SELECT status_page_id FROM status_page_sub_monitors WHERE monitor_id = $1
) affected`,
[monitorId]
);
const affectedCount = result.rows[0]?.count ?? 0;
await this.pool.query(`DELETE FROM status_page_monitors WHERE monitor_id = $1`, [monitorId]);
await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE monitor_id = $1`, [monitorId]);
return affectedCount;
};
private loadEntity = async (row: StatusPageRow): Promise<StatusPage> => {
const [monitorsResult, subMonitorsResult] = await Promise.all([
this.pool.query(`SELECT monitor_id FROM status_page_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
this.pool.query(`SELECT monitor_id FROM status_page_sub_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]),
]);
return {
id: row.id,
userId: row.user_id,
teamId: row.team_id,
type: Array.isArray(row.types) ? row.types : parsePostgresArray(row.types),
companyName: row.company_name,
url: row.url,
timezone: row.timezone ?? undefined,
color: row.color,
monitors: monitorsResult.rows.map((r) => r.monitor_id),
subMonitors: subMonitorsResult.rows.map((r) => r.monitor_id),
originalMonitors: [],
logo: row.logo_data
? {
data: row.logo_data.toString("base64"),
contentType: row.logo_content_type ?? "",
}
: undefined,
isPublished: row.is_published,
showCharts: row.show_charts,
showUptimePercentage: row.show_uptime_percentage,
showAdminLoginLink: row.show_admin_login_link,
showInfrastructure: row.show_infrastructure,
customCSS: row.custom_css ?? "",
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
};
}
@@ -1,24 +0,0 @@
import type { Pool } from "pg";
import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js";
import { Team } from "@/types/team.js";
export class TimescaleTeamsRepository implements ITeamsRepository {
constructor(private pool: Pool) {}
create = async (email: string): Promise<Team> => {
const result = await this.pool.query(`INSERT INTO teams (email) VALUES ($1) RETURNING id, email, created_at, updated_at`, [email]);
return this.toEntity(result.rows[0]);
};
findAllTeamIds = async (): Promise<string[]> => {
const result = await this.pool.query(`SELECT id FROM teams`);
return result.rows.map((row) => row.id);
};
private toEntity = (row: { id: string; email: string; created_at: Date; updated_at: Date }): Team => ({
id: row.id,
email: row.email,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
@@ -1,222 +0,0 @@
import type { Pool } from "pg";
import { IUsersRepository } from "./IUsersRepository.js";
import type { User, UserRole } from "@/types/user.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "TimescaleUsersRepository";
// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array
const parsePostgresArray = (value: unknown): UserRole[] => {
if (typeof value !== "string") return [];
const inner = value.replace(/^\{|\}$/g, "");
if (inner === "") return [];
return inner.split(",") as UserRole[];
};
interface UserRow {
id: string;
team_id: string;
first_name: string;
last_name: string;
email: string;
password: string;
avatar_image: string | null;
profile_image: Buffer | null;
profile_image_content_type: string | null;
is_active: boolean;
is_verified: boolean;
roles: UserRole[];
check_ttl: number | null;
created_at: Date;
updated_at: Date;
}
export class TimescaleUsersRepository implements IUsersRepository {
constructor(private pool: Pool) {}
create = async (user: Partial<User>, imageFile?: Express.Multer.File | null): Promise<User> => {
let avatarImage: string | undefined;
let profileImage: Buffer | undefined;
let profileImageContentType: string | undefined;
if (imageFile) {
profileImage = imageFile.buffer;
profileImageContentType = imageFile.mimetype;
avatarImage = await GenerateAvatarImage(imageFile);
}
const result = await this.pool.query<UserRow>(
`INSERT INTO users (team_id, first_name, last_name, email, password, avatar_image, profile_image, profile_image_content_type, is_active, is_verified, roles, check_ttl)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
[
user.teamId || null,
user.firstName,
user.lastName,
user.email,
user.password,
avatarImage || null,
profileImage || null,
profileImageContentType || null,
user.isActive ?? true,
user.isVerified ?? false,
user.role ?? ["user"],
user.checkTTL ?? null,
]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 });
}
const entity = this.toEntity(row);
entity.password = "";
return entity;
};
findByEmail = async (email: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users WHERE email = $1`,
[email]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findById = async (id: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users WHERE id = $1`,
[id]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findAll = async (): Promise<User[]> => {
const result = await this.pool.query<UserRow>(
`SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at
FROM users`
);
return result.rows.map(this.toEntity);
};
updateById = async (id: string, patch: Partial<User & { deleteProfileImage?: boolean }>, file?: Express.Multer.File | null): Promise<User> => {
const sets: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (ParseBoolean(patch.deleteProfileImage) === true) {
sets.push(`profile_image = NULL`);
sets.push(`profile_image_content_type = NULL`);
sets.push(`avatar_image = NULL`);
} else if (file) {
const avatarImage = await GenerateAvatarImage(file);
sets.push(`profile_image = $${paramIndex++}`);
values.push(file.buffer);
sets.push(`profile_image_content_type = $${paramIndex++}`);
values.push(file.mimetype);
sets.push(`avatar_image = $${paramIndex++}`);
values.push(avatarImage);
}
if (patch.firstName !== undefined) {
sets.push(`first_name = $${paramIndex++}`);
values.push(patch.firstName);
}
if (patch.lastName !== undefined) {
sets.push(`last_name = $${paramIndex++}`);
values.push(patch.lastName);
}
if (patch.email !== undefined) {
sets.push(`email = $${paramIndex++}`);
values.push(patch.email);
}
if (patch.password !== undefined) {
sets.push(`password = $${paramIndex++}`);
values.push(patch.password);
}
if (patch.isActive !== undefined) {
sets.push(`is_active = $${paramIndex++}`);
values.push(patch.isActive);
}
if (patch.isVerified !== undefined) {
sets.push(`is_verified = $${paramIndex++}`);
values.push(patch.isVerified);
}
if (patch.role !== undefined) {
sets.push(`roles = $${paramIndex++}`);
values.push(patch.role);
}
if (patch.teamId !== undefined) {
sets.push(`team_id = $${paramIndex++}`);
values.push(patch.teamId);
}
if (patch.checkTTL !== undefined) {
sets.push(`check_ttl = $${paramIndex++}`);
values.push(patch.checkTTL);
}
if (sets.length === 0) {
return this.findById(id);
}
sets.push(`updated_at = NOW()`);
values.push(id);
const result = await this.pool.query<UserRow>(
`UPDATE users SET ${sets.join(", ")} WHERE id = $${paramIndex}
RETURNING id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
values
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
deleteById = async (id: string): Promise<User> => {
const result = await this.pool.query<UserRow>(
`DELETE FROM users WHERE id = $1
RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`,
[id]
);
const row = result.rows[0];
if (!row) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(row);
};
findSuperAdmin = async (): Promise<boolean> => {
const result = await this.pool.query(`SELECT 1 FROM users WHERE 'superadmin' = ANY(roles) LIMIT 1`);
return (result.rowCount ?? 0) > 0;
};
private toEntity = (row: UserRow): User => ({
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
email: row.email,
password: row.password,
avatarImage: row.avatar_image ?? undefined,
profileImage: undefined,
isActive: row.is_active,
isVerified: row.is_verified,
role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles),
teamId: row.team_id ?? "",
checkTTL: row.check_ttl ?? undefined,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
});
}
+2 -2
View File
@@ -32,8 +32,8 @@ class AuthRoutes {
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser);
this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser);
}
getRouter() {
+1
View File
@@ -31,6 +31,7 @@ class MonitorRoutes {
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors);
// Util routes
this.router.get("/certificate/:monitorId", (req, res, next) => {
+10 -10
View File
@@ -107,16 +107,16 @@ export class CheckService implements ICheckService {
numericUnit: audit.numericUnit,
};
};
check.accessibility = (categories?.accessibility?.score || 0) * 100;
check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100;
check.seo = (categories?.seo?.score || 0) * 100;
check.performance = (categories?.performance?.score || 0) * 100;
check.accessibility = (categories.accessibility?.score || 0) * 100;
check.bestPractices = (categories["best-practices"]?.score || 0) * 100;
check.seo = (categories.seo?.score || 0) * 100;
check.performance = (categories.performance?.score || 0) * 100;
check.audits = {
cls: mapAudit(audits?.["cumulative-layout-shift"]),
si: mapAudit(audits?.["speed-index"]),
fcp: mapAudit(audits?.["first-contentful-paint"]),
lcp: mapAudit(audits?.["largest-contentful-paint"]),
tbt: mapAudit(audits?.["total-blocking-time"]),
cls: mapAudit(audits["cumulative-layout-shift"]),
si: mapAudit(audits["speed-index"]),
fcp: mapAudit(audits["first-contentful-paint"]),
lcp: mapAudit(audits["largest-contentful-paint"]),
tbt: mapAudit(audits["total-blocking-time"]),
};
}
@@ -124,7 +124,7 @@ export class CheckService implements ICheckService {
const hardwarePayload = payload as HardwareStatusPayload | undefined;
const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {};
const errorsSource = Array.isArray(hardwarePayload?.errors)
? hardwarePayload?.errors
? hardwarePayload.errors
: (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors;
check.cpu = cpu;
check.memory = memory;
@@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService {
}
}
if (decision.shouldResolveIncident) {
if (!activeIncident) {
return null;
}
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
if (!decision.shouldResolveIncident || !activeIncident) {
return null;
}
return null;
activeIncident.status = false;
activeIncident.endTime = Date.now().toString();
activeIncident.resolutionType = "automatic";
return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident);
};
private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string {
+54 -41
View File
@@ -9,7 +9,8 @@ import type {
GamesMap,
GroupedGeoCheckResult,
} from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js";
import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js";
import type { GeoContinent } from "@/types/geoCheck.js";
import type {
IChecksRepository,
@@ -23,12 +24,14 @@ import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "../infrastructure/emailService.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult =>
supportsUptimeDetails(result.monitorType);
export interface IMonitorService {
readonly serviceName: string;
@@ -74,6 +77,7 @@ export interface IMonitorService {
// update
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
@@ -91,7 +95,6 @@ export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
private emailService: IEmailService;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
@@ -103,7 +106,6 @@ export class MonitorService implements IMonitorService {
constructor({
jobQueue,
emailService,
logger,
games,
monitorsRepository,
@@ -114,7 +116,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
emailService: IEmailService;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
@@ -125,7 +126,6 @@ export class MonitorService implements IMonitorService {
incidentsRepository: IIncidentsRepository;
}) {
this.jobQueue = jobQueue;
this.emailService = emailService;
this.logger = logger;
this.games = games;
this.monitorsRepository = monitorsRepository;
@@ -176,7 +176,7 @@ export class MonitorService implements IMonitorService {
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
}
@@ -185,8 +185,7 @@ export class MonitorService implements IMonitorService {
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
const monitors = demoMonitorsData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
@@ -214,22 +213,14 @@ export class MonitorService implements IMonitorService {
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
});
const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id);
if (
checksData.monitorType !== "http" &&
checksData.monitorType !== "ping" &&
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
checksData.monitorType !== "game" &&
checksData.monitorType !== "grpc" &&
checksData.monitorType !== "websocket"
) {
if (!isUptimeChecksResult(checksData)) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
@@ -263,7 +254,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -305,7 +296,7 @@ export class MonitorService implements IMonitorService {
throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 });
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), {
type: monitor.type,
@@ -346,7 +337,7 @@ export class MonitorService implements IMonitorService {
return { groupedGeoChecks: [] };
}
const rangeKey = (dateRange as DateRangeKey) ?? "recent";
const rangeKey = dateRange as DateRangeKey;
const { start, end } = this.getDateRange(rangeKey);
const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange(
monitor.id,
@@ -360,8 +351,7 @@ export class MonitorService implements IMonitorService {
};
getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
return monitor;
return await this.monitorsRepository.findById(monitorId, teamId);
};
getMonitorsByTeamId = async ({
@@ -373,11 +363,7 @@ export class MonitorService implements IMonitorService {
type?: MonitorType | MonitorType[];
filter?: string;
}): Promise<Monitor[] | null> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {
type,
filter,
});
return monitors;
return await this.monitorsRepository.findByTeamId(teamId, { type, filter });
};
getMonitorsWithChecksByTeamId = async ({
@@ -432,8 +418,7 @@ export class MonitorService implements IMonitorService {
};
getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise<string[]> => {
const groups = await this.monitorsRepository.findGroupsByTeamId(teamId);
return groups;
return await this.monitorsRepository.findGroupsByTeamId(teamId);
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
@@ -474,6 +459,43 @@ export class MonitorService implements IMonitorService {
return monitor;
};
bulkPauseMonitors = async ({
teamId,
monitorIds,
pause,
}: {
teamId: string;
monitorIds: string[];
pause: boolean;
}): Promise<{ monitors: Monitor[]; failedCount: number }> => {
const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause);
const results = await Promise.allSettled(
monitors.map(async (monitor) => {
if (monitor.isActive) {
await this.jobQueue.resumeJob(monitor);
} else {
await this.jobQueue.pauseJob(monitor);
}
})
);
let failedCount = 0;
results.forEach((result, index) => {
if (result.status === "rejected") {
failedCount++;
this.logger.error({
message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`,
service: SERVICE_NAME,
method: "bulkPauseMonitors",
stack: result.reason instanceof Error ? result.reason.stack : undefined,
});
}
});
return { monitors, failedCount };
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
@@ -574,15 +596,6 @@ export class MonitorService implements IMonitorService {
const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({
message: "Failed to import any monitors. Please check the file format and try again.",
service: SERVICE_NAME,
method: "importMonitorsFromJSON",
status: 400,
});
}
return { imported: createdMonitors.length, errors };
return { imported: createdMonitors!.length, errors };
};
}
@@ -1,5 +1,7 @@
import { type IStatusPagesRepository } from "@/repositories/index.js";
import { StatusPage } from "@/types/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js";
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
getStatusPageByUrl(url: string): Promise<StatusPage>;
@@ -11,29 +13,52 @@ export interface IStatusPageService {
export class StatusPageService implements IStatusPageService {
private statusPagesRepository: IStatusPagesRepository;
constructor(statusPagesRepository: IStatusPagesRepository) {
private settingsService: ISettingsService;
constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) {
this.statusPagesRepository = statusPagesRepository;
this.settingsService = settingsService;
}
private withoutThemeFields = (data: Partial<StatusPage>): Partial<StatusPage> => {
const { theme: _theme, themeMode: _themeMode, ...rest } = data;
return rest;
};
private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
...statusPage,
theme: DEFAULT_STATUS_PAGE_THEME,
themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
});
private normalizeTheme = (statusPage: StatusPage): StatusPage =>
this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage);
private normalizeInput = (data: Partial<StatusPage>): Partial<StatusPage> =>
this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data);
createStatusPage = async (
userId: string,
teamId: string,
image: Express.Multer.File | undefined,
data: Partial<StatusPage>
): Promise<StatusPage> => {
return await this.statusPagesRepository.create(userId, teamId, image, data);
const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data));
return this.normalizeTheme(created);
};
getStatusPageByUrl = async (url: string): Promise<StatusPage> => {
return await this.statusPagesRepository.findByUrl(url);
const statusPage = await this.statusPagesRepository.findByUrl(url);
return this.normalizeTheme(statusPage);
};
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
return await this.statusPagesRepository.findByTeamId(teamId);
const statusPages = await this.statusPagesRepository.findByTeamId(teamId);
return statusPages.map((sp) => this.normalizeTheme(sp));
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
return await this.statusPagesRepository.updateById(id, teamId, image, data);
const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data));
return this.normalizeTheme(updated);
};
deleteStatusPage = async (statusPageId: string, teamId: string): Promise<StatusPage> => {
+8 -24
View File
@@ -110,10 +110,7 @@ export class UserService implements IUserService {
}
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL });
};
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
@@ -253,14 +250,10 @@ export class UserService implements IUserService {
currentUserEmail: string
) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
if (updates.password && updates.newPassword) {
updates.email = currentUserEmail;
// Get user
const user = await this.usersRepository.findByEmail(currentUserEmail);
// Compare passwords
const match = await bcrypt.compare(updates?.password, user.password);
const match = await bcrypt.compare(updates.password, user.password);
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
@@ -286,7 +279,7 @@ export class UserService implements IUserService {
const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName;
const settings = this.settingsService.getSettings();
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`;
const html = await this.emailService.buildEmail("passwordResetTemplate", {
name,
@@ -336,17 +329,10 @@ export class UserService implements IUserService {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
// 1. Find all the monitors associated with the team ID if superadmin
const res = await this.monitorsRepository.findByTeamId(teamId, {});
if (roles.includes("superadmin")) {
// 2. Remove all jobs, delete checks and alerts
if (res && res.length > 0) {
await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor);
})
);
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (monitors) {
await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor)));
}
}
// 6. Delete the user by id
@@ -410,9 +396,7 @@ export class UserService implements IUserService {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
return user;
return await this.usersRepository.findById(userId);
};
editUserById = async (userId: string, patch: Partial<User>) => {
+2
View File
@@ -30,6 +30,8 @@ export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
export * from "@/service/infrastructure/notificationProviders/telegram.js";
export * from "@/service/infrastructure/notificationProviders/pushover.js";
export * from "@/service/infrastructure/notificationProviders/twilio.js";
// System services
export * from "@/service/system/settingsService.js";
@@ -28,6 +28,8 @@ type QueueJobSummary = {
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedAt: number | null;
runCount: number;
@@ -37,6 +39,7 @@ type QueueJobSummary = {
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export interface ISuperSimpleQueue {
@@ -51,7 +54,6 @@ export interface ISuperSimpleQueue {
getMetrics(): Promise<QueueMetrics>;
getJobs(): Promise<QueueJobSummary[]>;
flushQueues(): Promise<{ success: boolean }>;
obliterate(): Promise<void>;
}
export class SuperSimpleQueue implements ISuperSimpleQueue {
@@ -73,22 +75,91 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return SuperSimpleQueue.SERVICE_NAME;
}
private registerListeners = () => {
this.scheduler.on("scheduler:start", () => {
this.logger.info({
message: "Scheduler started",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:stop", () => {
this.logger.info({
message: "Scheduler stopped",
service: SERVICE_NAME,
});
});
this.scheduler.on("scheduler:error", (error) => {
this.logger.error({
message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:abort", (job, reason) => {
this.logger.warn({
message: `${job.id} aborted: ${reason}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:attempt", (job, attempt) => {
this.logger.debug({
message: `${job.id} attempt ${attempt}`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:complete", (job) => {
this.logger.debug({
message: `${job.id} completed successfully`,
service: SERVICE_NAME,
});
});
this.scheduler.on("job:exhausted", (job, error) => {
this.logger.error({
message: `${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:fail", (job, error, attempt) => {
this.logger.warn({
message: `${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`,
service: SERVICE_NAME,
stack: error instanceof Error ? error.stack : undefined,
});
});
this.scheduler.on("job:start", (job) => {
this.logger.debug({
message: `${job.id} started`,
service: SERVICE_NAME,
});
});
};
static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) {
const scheduler = new Scheduler({
// storeType: "mongo",
// storeType: "redis",
logLevel: "debug",
// dbUri: envSettings.dbConnectionString,
});
const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler);
await instance.init();
return instance;
}
init = async () => {
try {
this.scheduler.start();
this.registerListeners();
this.scheduler.start();
this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob());
this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob());
this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob());
@@ -155,8 +226,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to pause monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to pause geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
@@ -170,9 +253,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
await this.scheduler.resumeJob(`${monitor.id}-geo`);
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to resume geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -237,8 +331,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (failCount > 0) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
failedAt: job.lastFailedAt ?? null,
failCount,
failReason: job.lastFailReason ?? null,
@@ -270,10 +364,13 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
return jobs.map((job) => {
return {
monitorId: job.id,
monitorUrl: job?.data?.url || null,
monitorType: job?.data?.type || null,
monitorInterval: job?.data?.interval || null,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
monitorGeoInterval: job.data?.geoCheckInterval || null,
monitorActive: job.data?.isActive ?? null,
active: job.active,
repeat: job.repeat ?? null,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
failCount: job.failCount ?? 0,
@@ -294,12 +391,4 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
success: Boolean(stopRes && flushRes && initRes),
};
};
obliterate = async () => {
this.logger.warn({
message: "obliterate method not implemented",
service: SERVICE_NAME,
method: "obliterate",
});
};
}
@@ -168,15 +168,17 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
});
}
// Step 7. Handle incidents (best effort, don't wait)
this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => {
// Step 7. Handle incidents
try {
await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status);
} catch (error: unknown) {
this.logger.warn({
message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
service: SERVICE_NAME,
method: "getMonitorJob",
stack: error instanceof Error ? error.stack : undefined,
});
});
}
} catch (error: unknown) {
this.logger.warn({
message: error instanceof Error ? error.message : "Unknown error",
@@ -377,7 +379,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper {
return true;
}
}
return false;
return acc;
}
return acc;
}, false);
@@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService";
export interface IBufferService {
addToBuffer(check: Check): void;
addGeoCheckToBuffer(geoCheck: GeoCheck): void;
removeCheckFromBuffer(check: Check): boolean;
scheduleNextFlush(): void;
flushBuffer(): Promise<void>;
flushGeoBuffer(): Promise<void>;
@@ -72,44 +71,6 @@ export class BufferService implements IBufferService {
}
}
removeCheckFromBuffer(checkToRemove: Check) {
try {
if (!checkToRemove) {
return false;
}
const index = this.buffer.findIndex((check) => {
if (checkToRemove.id && check.id) {
return check.id.toString() === checkToRemove.id.toString();
}
return (
check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId &&
check.metadata.teamId?.toString() === checkToRemove.metadata.teamId &&
check.metadata.type === checkToRemove.metadata.type &&
check.status === checkToRemove.status &&
check.statusCode === checkToRemove.statusCode &&
check.responseTime === checkToRemove.responseTime &&
check.message === checkToRemove.message
);
});
if (index !== -1) {
this.buffer.splice(index, 1);
return true;
}
return false;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: this.SERVICE_NAME,
method: "removeCheckFromBuffer",
stack: error instanceof Error ? error.stack : undefined,
});
return false;
}
}
scheduleNextFlush() {
if (this.bufferTimer) {
clearTimeout(this.bufferTimer);
@@ -35,7 +35,6 @@ export class EmailService implements IEmailService {
private logger: ILogger;
private transporter: ReturnType<typeof import("nodemailer").createTransport> | null = null;
private templateLookup: Record<string, ((context: Record<string, unknown>) => string) | undefined>;
private loadTemplate: (templateName: string) => ((context: Record<string, unknown>) => string) | undefined;
constructor(
settingsService: ISettingsService,
@@ -54,7 +53,6 @@ export class EmailService implements IEmailService {
this.nodemailer = nodemailer;
this.logger = logger;
this.templateLookup = {};
this.loadTemplate = () => undefined;
this.init();
}
@@ -63,7 +61,7 @@ export class EmailService implements IEmailService {
}
init = () => {
this.loadTemplate = (templateName) => {
const loadTemplate = (templateName: string) => {
try {
const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`);
const templateContent = this.fs.readFileSync(templatePath, "utf8");
@@ -79,12 +77,12 @@ export class EmailService implements IEmailService {
};
this.templateLookup = {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
welcomeEmailTemplate: loadTemplate("welcomeEmail"),
employeeActivationTemplate: loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"),
passwordResetTemplate: loadTemplate("passwordReset"),
testEmailTemplate: loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: loadTemplate("unifiedNotification"),
};
};
@@ -120,6 +118,7 @@ export class EmailService implements IEmailService {
systemEmailPool,
systemEmailUser,
systemEmailAddress,
systemEmailDisplayName,
systemEmailPassword,
systemEmailConnectionHost,
systemEmailTLSServername,
@@ -160,14 +159,17 @@ export class EmailService implements IEmailService {
return false;
}
const trimmedDisplayName = systemEmailDisplayName?.trim();
const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress;
try {
const info = await this.transporter.sendMail({
to: to,
from: systemEmailAddress,
from: from,
subject: subject,
html: html,
});
return info?.messageId;
return info.messageId;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
@@ -0,0 +1,104 @@
import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js";
import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js";
import type { Resolver } from "dns/promises";
import { Monitor, MonitorType } from "@/types/monitor.js";
import { AppError } from "@/utils/AppError.js";
import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js";
const SERVICE_NAME = "DNSProvider";
export class DNSProvider implements IStatusProvider<DNSStatusPayload> {
readonly type = "dns";
constructor(private createResolver: () => Resolver) {}
supports(type: MonitorType) {
return type === "dns";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DNSStatusPayload>> {
try {
const { url, dnsServer, dnsRecordType = "A" } = monitor;
if (!dnsServer) {
throw new Error("DNS server is required for DNS monitoring");
}
let hostname: string;
try {
const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing
hostname = new URL(withScheme).hostname;
} catch {
hostname = url;
}
const resolver = this.createResolver();
resolver.setServers([dnsServer]);
const {
response: results,
responseTime,
error,
} = await timeRequest(() => {
switch (dnsRecordType.toUpperCase()) {
case "A":
return resolver.resolve4(hostname);
case "AAAA":
return resolver.resolve6(hostname);
case "CNAME":
return resolver.resolveCname(hostname);
case "MX":
return resolver.resolveMx(hostname);
case "TXT":
return resolver.resolveTxt(hostname);
case "NS":
return resolver.resolveNs(hostname);
default:
return resolver.resolve(hostname, dnsRecordType);
}
});
if (error) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: error instanceof Error ? error.message : String(error),
responseTime,
payload: {
hostname,
dnsServer,
recordType: dnsRecordType,
resolved: false,
results,
},
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: true,
code: 200,
message: "Success",
responseTime,
payload: {
hostname,
dnsServer,
recordType: dnsRecordType,
resolved: true,
results,
},
};
} catch (error: unknown) {
const originalMessage = error instanceof Error ? error.message : String(error);
throw new AppError({
message: originalMessage || "Error performing DNS check",
status: 500,
service: SERVICE_NAME,
method: "handle",
details: { url: monitor.url },
});
}
}
}
@@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID");
if (matchTypes.length > 1) {
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`;
const matchUsed = exactIdMatch ? "exact ID" : "exact name";
const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`;
this.logger.warn({
message,
@@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
};
}
if (!response) {
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: "No response from Docker container inspect",
responseTime,
payload: null,
};
}
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: response?.State?.Status === "running",
status: response.State?.Status === "running",
code: 200,
message: "Docker container status fetched successfully",
responseTime,
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" });
}
const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0];
const target = `${host}:${port}`;
const currentFilePath = fileURLToPath(import.meta.url);
@@ -118,7 +118,7 @@ export class NetworkService implements INetworkService {
},
});
if (response?.data?.status !== "success") return false;
if (response.data?.status !== "success") return false;
return true;
} catch (err: unknown) {
const originalMessage = err instanceof Error ? err.message : String(err);
@@ -1,7 +1,30 @@
import type { Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { ILogger } from "@/utils/logger.js";
import type { OptionsOfJSONResponseBody } from "got";
export const DEFAULT_NOTIFICATION_TIMEOUT_MS = 30_000;
export interface INotificationProvider {
sendMessage: (notification: Notification, message: NotificationMessage) => Promise<boolean>;
sendTestAlert(notification: Partial<Notification>): Promise<boolean>;
}
export abstract class NotificationProvider implements INotificationProvider {
protected readonly logger: ILogger;
protected readonly timeoutMs: number;
constructor(logger: ILogger, timeoutMs: number = DEFAULT_NOTIFICATION_TIMEOUT_MS) {
this.logger = logger;
this.timeoutMs = timeoutMs;
}
protected gotRequestOptions(): Pick<OptionsOfJSONResponseBody, "timeout" | "retry"> {
return {
timeout: { request: this.timeoutMs },
retry: { limit: 0 },
};
}
abstract sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean>;
abstract sendTestAlert(notification: Partial<Notification>): Promise<boolean>;
}
@@ -1,18 +1,11 @@
const SERVICE_NAME = "DiscordProvider";
import type { AlertDiscordPayload, DiscordEmbedField, Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage, NotificationSeverity } from "@/types/notificationMessage.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class DiscordProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
export class DiscordProvider extends NotificationProvider {
sendTestAlert = async (notification: Partial<Notification>) => {
if (!notification.address) {
return false;
@@ -23,6 +16,7 @@ export class DiscordProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -55,6 +49,7 @@ export class DiscordProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -1,17 +1,16 @@
const SERVICE_NAME = "EmailProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import type { ILogger } from "@/utils/logger.js";
import { IEmailService } from "@/service/infrastructure/emailService.js";
export class EmailProvider implements INotificationProvider {
export class EmailProvider extends NotificationProvider {
private emailService: IEmailService;
private logger: ILogger;
constructor(emailService: IEmailService, logger: ILogger) {
super(logger);
this.emailService = emailService;
this.logger = logger;
}
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
@@ -1,80 +1,63 @@
const SERVICE_NAME = "MatrixProvider";
import got from "got";
import type { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { AlertMatrixPayload, Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { ILogger } from "@/utils/logger.js";
export class MatrixProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
import { randomUUID } from "crypto";
export class MatrixProvider extends NotificationProvider {
sendTestAlert = async (notification: Partial<Notification>) => {
const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) {
return false;
}
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: getTestMessage(),
};
try {
await got.post(url, {
json: body,
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: err?.stack,
});
return false;
}
return await this.sendMessageWithBody(notification, body);
};
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) {
return false;
}
const { plainText, htmlText } = this.buildMatrixMessage(message);
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: plainText,
format: "org.matrix.custom.html",
formatted_body: htmlText,
};
return this.sendMessageWithBody(notification, body);
};
private sendMessageWithBody = async (notification: Partial<Notification>, body: unknown): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
const txnId = randomUUID();
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`;
try {
await got.post(url, {
await got.put(url, {
json: body,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Matrix notification failed",
message: `Matrix notification failed : ${err.message}`,
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
stack: err.stack,
});
return false;
}
@@ -1,19 +1,12 @@
const SERVICE_NAME = "PagerDutyProvider";
import got from "got";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { ILogger } from "@/utils/logger.js";
import { AlertPagerDutyPayload } from "@/types/index.js";
export class PagerDutyProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
export class PagerDutyProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
try {
await got.post("https://events.pagerduty.com/v2/enqueue", {
@@ -28,6 +21,7 @@ export class PagerDutyProvider implements INotificationProvider {
},
},
responseType: "json",
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -59,6 +53,7 @@ export class PagerDutyProvider implements INotificationProvider {
const payload = this.buildPagerDutyPayload(notification, message);
await got.post("https://events.pagerduty.com/v2/enqueue", {
json: payload,
...this.gotRequestOptions(),
});
this.logger.info({
message: "[NEW] PagerDuty notification sent via sendMessage",
@@ -0,0 +1,109 @@
const SERVICE_NAME = "PushoverProvider";
import type { Notification } from "@/types/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class PushoverProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
try {
await got.post("https://api.pushover.net/1/messages.json", {
form: {
token: notification.accessToken,
user: notification.address,
message: getTestMessage(),
title: "Checkmate Test Notification",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Pushover test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
}
const text = this.buildPushoverText(message);
try {
await got.post("https://api.pushover.net/1/messages.json", {
form: {
token: notification.accessToken,
user: notification.address,
message: text,
title: message.content.title,
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Pushover notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Pushover alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildPushoverText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.summary);
lines.push("");
lines.push("Monitor Details:");
lines.push(`• Name: ${message.monitor.name}`);
lines.push(`• URL: ${message.monitor.url}`);
lines.push(`• Type: ${message.monitor.type}`);
lines.push(`• Status: ${message.monitor.status}`);
if (message.content.details && message.content.details.length > 0) {
lines.push("");
lines.push("Additional Information:");
message.content.details.forEach((detail) => lines.push(`• ${detail}`));
}
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("");
lines.push("Threshold Breaches:");
message.content.thresholds.forEach((breach) => {
lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
}
if (message.content.incident) {
lines.push("");
lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`);
}
return lines.join("\n");
}
}
@@ -1,18 +1,11 @@
const SERVICE_NAME = "SlackProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got, { HTTPError } from "got";
import { ILogger } from "@/utils/logger.js";
export class SlackProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
export class SlackProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address) {
return false;
@@ -24,6 +17,7 @@ export class SlackProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -54,6 +48,7 @@ export class SlackProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "[NEW] Slack notification sent via sendMessage",
@@ -1,9 +1,8 @@
const SERVICE_NAME = "TeamsProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import type { ILogger } from "@/utils/logger.js";
import got, { HTTPError } from "got";
// Types for Adaptive Card elements
@@ -58,13 +57,7 @@ type TeamsMessage = {
}>;
};
export class TeamsProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
export class TeamsProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address) {
return false;
@@ -90,6 +83,7 @@ export class TeamsProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -117,6 +111,7 @@ export class TeamsProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Teams notification sent via sendMessage",
@@ -1,18 +1,11 @@
const SERVICE_NAME = "TelegramProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class TelegramProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
export class TelegramProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.address || !notification.accessToken) {
return false;
@@ -25,6 +18,7 @@ export class TelegramProvider implements INotificationProvider {
text: getTestMessage(),
parse_mode: "HTML",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -55,7 +49,9 @@ export class TelegramProvider implements INotificationProvider {
text,
parse_mode: "HTML",
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Telegram notification sent",
service: SERVICE_NAME,
@@ -0,0 +1,96 @@
const SERVICE_NAME = "TwilioProvider";
import type { Notification } from "@/types/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class TwilioProvider extends NotificationProvider {
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) {
return false;
}
try {
await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, {
form: {
To: notification.phone,
From: notification.twilioPhoneNumber,
Body: getTestMessage(),
},
username: notification.accountSid,
password: notification.accessToken,
...this.gotRequestOptions(),
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Twilio test alert failed",
service: SERVICE_NAME,
method: "sendTestAlert",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) {
return false;
}
const text = this.buildSmsText(message);
try {
await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, {
form: {
To: notification.phone,
From: notification.twilioPhoneNumber,
Body: text,
},
username: notification.accountSid,
password: notification.accessToken,
...this.gotRequestOptions(),
});
this.logger.info({
message: "Twilio SMS notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const errMsg = error instanceof Error ? error.message : "unknown error";
const errStack = error instanceof Error ? error.stack : undefined;
this.logger.warn({
message: "Twilio SMS alert failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: errStack,
details: { error: errMsg },
});
return false;
}
}
private buildSmsText(message: NotificationMessage): string {
const lines: string[] = [];
lines.push(message.content.title);
lines.push(message.content.summary);
lines.push("");
lines.push(`URL: ${message.monitor.url}`);
lines.push(`Status: ${message.monitor.status}`);
if (message.content.thresholds && message.content.thresholds.length > 0) {
message.content.thresholds.forEach((breach) => {
lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`);
});
}
return lines.join("\n");
}
}
@@ -1,18 +1,11 @@
const SERVICE_NAME = "WebhookProvider";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
import { ILogger } from "@/utils/logger.js";
export class WebhookProvider implements INotificationProvider {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
export class WebhookProvider extends NotificationProvider {
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
if (!notification.address) {
return false;
@@ -27,6 +20,7 @@ export class WebhookProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
this.logger.info({
message: "Webhook notification sent",
@@ -108,6 +102,7 @@ export class WebhookProvider implements INotificationProvider {
headers: {
"Content-Type": "application/json",
},
...this.gotRequestOptions(),
});
return true;
} catch (error) {
@@ -34,6 +34,8 @@ export class NotificationsService implements INotificationsService {
private matrixProvider: INotificationProvider;
private teamsProvider: INotificationProvider;
private telegramProvider: INotificationProvider;
private pushoverProvider: INotificationProvider;
private twilioProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
@@ -49,6 +51,8 @@ export class NotificationsService implements INotificationsService {
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
telegramProvider: INotificationProvider,
pushoverProvider: INotificationProvider,
twilioProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
@@ -63,6 +67,8 @@ export class NotificationsService implements INotificationsService {
this.matrixProvider = matrixProvider;
this.teamsProvider = teamsProvider;
this.telegramProvider = telegramProvider;
this.pushoverProvider = pushoverProvider;
this.twilioProvider = twilioProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
@@ -102,6 +108,10 @@ export class NotificationsService implements INotificationsService {
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
case "telegram":
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
case "pushover":
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
case "twilio":
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
@@ -164,6 +174,10 @@ export class NotificationsService implements INotificationsService {
return await this.teamsProvider.sendTestAlert(notification);
case "telegram":
return await this.telegramProvider.sendTestAlert(notification);
case "pushover":
return await this.pushoverProvider.sendTestAlert(notification);
case "twilio":
return await this.twilioProvider.sendTestAlert(notification);
default:
return false;
}
@@ -200,8 +214,8 @@ export class NotificationsService implements INotificationsService {
};
deleteById = async (id: string, teamId: string): Promise<Notification> => {
const deleted = await this.notificationsRepository.deleteById(id, teamId);
await this.monitorsRepository.removeNotificationFromMonitors(id);
const deleted = await this.notificationsRepository.deleteById(id, teamId);
return deleted;
};
}
+199 -255
View File
@@ -14,13 +14,20 @@ import type {
GameStatusPayload,
GrpcStatusPayload,
CheckSnapshot,
MonitorStats,
CheckDiskInfo,
} from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "./bufferService.js";
import type { HardwareStatusMetrics } from "@/types/network.js";
import { MAX_RECENT_CHECKS } from "@/types/index.js";
const SERVICE_NAME = "StatusService";
const HARDWARE_ALERT_COUNTER_START = 5;
const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const;
type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number];
type HardwareBreaches = Record<HardwareMetricKey, boolean>;
type HardwareCounters = Record<HardwareMetricKey, number>;
export interface IStatusService {
updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise<boolean>;
@@ -68,103 +75,11 @@ export class StatusService implements IStatusService {
async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) {
try {
const monitorId = monitor.id;
const { responseTime, status } = networkResponse;
let existingStats: MonitorStats | null = null;
existingStats = await this.monitorStatsRepository
.findByMonitorId(monitorId)
.then((result) => result)
.catch(() => {
this.logger.debug({
service: SERVICE_NAME,
method: "updateRunningStats",
message: `No existing stats found for monitor ${monitorId}, initializing new stats.`,
});
return null;
});
let stats: Omit<MonitorStats, "id" | "monitorId" | "createdAt" | "updatedAt">;
if (!existingStats) {
// Initialize new stats
stats = {
avgResponseTime: 0,
maxResponseTime: 0,
totalChecks: 0,
totalUpChecks: 0,
totalDownChecks: 0,
uptimePercentage: 0,
lastResponseTime: 0,
lastCheckTimestamp: 0,
};
} else {
// Use existing stats (omit id, monitorId, createdAt, updatedAt)
stats = {
avgResponseTime: existingStats.avgResponseTime,
maxResponseTime: existingStats.maxResponseTime,
totalChecks: existingStats.totalChecks,
totalUpChecks: existingStats.totalUpChecks,
totalDownChecks: existingStats.totalDownChecks,
uptimePercentage: existingStats.uptimePercentage,
lastResponseTime: existingStats.lastResponseTime,
lastCheckTimestamp: existingStats.lastCheckTimestamp,
timeOfLastFailure: existingStats.timeOfLastFailure,
};
}
// Update stats
stats.totalChecks++;
// Last response time
stats.lastResponseTime = responseTime ?? 0;
// Max response time
if (responseTime && responseTime > stats.maxResponseTime) {
stats.maxResponseTime = responseTime;
}
// Avg response time:
let avgResponseTime = stats.avgResponseTime;
if (typeof responseTime !== "undefined" && responseTime !== null) {
if (avgResponseTime === 0) {
avgResponseTime = responseTime;
} else {
avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + responseTime) / stats.totalChecks;
}
}
stats.avgResponseTime = avgResponseTime;
// Total checks
if (status === true) {
stats.totalUpChecks++;
// Update the timeSinceLastFailure if needed
if (stats.timeOfLastFailure === 0) {
stats.timeOfLastFailure = new Date().getTime();
}
} else {
stats.totalDownChecks++;
stats.timeOfLastFailure = 0;
}
// Calculate uptime percentage
let uptimePercentage;
if (stats.totalChecks > 0) {
uptimePercentage = stats.totalUpChecks / stats.totalChecks;
} else {
uptimePercentage = status === true ? 100 : 0;
}
stats.uptimePercentage = uptimePercentage;
// latest check
stats.lastCheckTimestamp = new Date().getTime();
// Create or update
if (!existingStats) {
await this.monitorStatsRepository.create({ monitorId, ...stats });
} else {
await this.monitorStatsRepository.updateByMonitorId(monitorId, stats);
}
await this.monitorStatsRepository.updateByMonitorId(monitor.id, {
status: networkResponse.status === true,
responseTime: networkResponse.responseTime ?? 0,
now: Date.now(),
});
return true;
} catch (error: unknown) {
this.logger.error({
@@ -177,6 +92,115 @@ export class StatusService implements IStatusService {
}
}
private tryUpdateRunningStats = async (monitor: Monitor, statusResponse: MonitorStatusResponse) => {
const statsOk = await this.updateRunningStats(monitor, statusResponse);
if (!statsOk) {
this.logger.warn({
service: SERVICE_NAME,
method: "updateMonitorStatus",
message: `Stats update failed for monitor ${monitor.id}`,
});
}
};
private toCheckSnapshot = (check: Check): CheckSnapshot => {
return {
id: check.id,
status: check.status,
responseTime: check.responseTime,
timings: check.timings,
statusCode: check.statusCode,
message: check.message,
cpu: check.cpu,
memory: check.memory,
disk: check.disk,
host: check.host,
errors: check.errors,
capture: check.capture,
net: check.net,
accessibility: check.accessibility,
bestPractices: check.bestPractices,
seo: check.seo,
performance: check.performance,
audits: check.audits,
createdAt: check.createdAt,
};
};
private computeReachability = (
currentStatus: MonitorStatus,
window: Array<boolean>,
threshold: number
): { nextStatus: "up" | "down"; transitioned: boolean } => {
const failures = window.filter((status) => status === false).length;
const failureRate = (failures / window.length) * 100;
if (failureRate >= threshold && currentStatus !== "down") {
return { nextStatus: "down", transitioned: true };
}
if (failureRate < threshold && currentStatus === "down") {
return { nextStatus: "up", transitioned: true };
}
return { nextStatus: "up", transitioned: false };
};
private computeHardwareStatus = (params: {
currentStatus: MonitorStatus;
reachabilityDown: boolean;
metrics: HardwareStatusMetrics;
thresholds: { cpu: number; memory: number; disk: number; temp: number };
counters: HardwareCounters;
}): {
nextStatus: MonitorStatus;
transitioned: boolean;
breaches: HardwareBreaches;
nextCounters: HardwareCounters;
} => {
const { metrics, thresholds, counters, currentStatus, reachabilityDown } = params;
const cpuUsage = metrics.cpu?.usage_percent ?? -1;
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const temps = metrics.cpu?.temperature ?? [];
const breaches: HardwareBreaches = {
cpu: cpuUsage !== -1 && cpuUsage > thresholds.cpu / 100,
memory: memoryUsage !== -1 && memoryUsage > thresholds.memory / 100,
disk: metrics.disk
? metrics.disk.some((d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > thresholds.disk / 100)
: false,
temp: temps.some((temp: number) => temp > thresholds.temp),
};
// Update counters: decrement (floored at 0) if breached, reset to start otherwise.
const nextCounters = { ...counters };
for (const key of HARDWARE_METRIC_KEYS) {
nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START;
}
// Status transition: reachability "down" takes precedence; hardware can only drive
// transitions into "breached" and back out to "up". Any other case leaves status untouched.
let nextStatus: MonitorStatus = currentStatus;
let transitioned = false;
if (!reachabilityDown) {
// A counter can only reach zero via the decrement path, which only runs when that
// metric is currently breaching — so anyCounterZero already implies anyBreached.
const anyCounterZero = HARDWARE_METRIC_KEYS.some((k) => nextCounters[k] === 0);
const allNormal = HARDWARE_METRIC_KEYS.every((k) => !breaches[k]);
if (anyCounterZero && currentStatus !== "breached") {
nextStatus = "breached";
transitioned = true;
} else if (allNormal && currentStatus === "breached") {
nextStatus = "up";
transitioned = true;
}
}
return { nextStatus, transitioned, breaches, nextCounters };
};
updateMonitorStatus = async (
statusResponse: MonitorStatusResponse<
| PingStatusPayload
@@ -196,159 +220,102 @@ export class StatusService implements IStatusService {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
// Update running stats
this.updateRunningStats(monitor, statusResponse);
monitor.statusWindow = monitor.statusWindow || [];
monitor.statusWindow.push(status);
while (monitor.statusWindow.length > monitor.statusWindowSize) {
monitor.statusWindow.shift();
}
const checkSnapshot: CheckSnapshot = {
id: check.id,
status: check.status,
responseTime: check.responseTime,
timings: check.timings,
statusCode: check.statusCode,
message: check.message,
cpu: check.cpu,
memory: check.memory,
disk: check.disk,
host: check.host,
errors: check.errors,
capture: check.capture,
net: check.net,
accessibility: check.accessibility,
bestPractices: check.bestPractices,
seo: check.seo,
performance: check.performance,
audits: check.audits,
createdAt: check.createdAt,
};
monitor.recentChecks = monitor.recentChecks || [];
monitor.recentChecks.push(checkSnapshot);
const maxRecentChecks = 25;
while (monitor.recentChecks.length > maxRecentChecks) {
monitor.recentChecks.shift();
}
await this.tryUpdateRunningStats(monitor, statusResponse);
const prevStatus = monitor.status;
let newStatus: MonitorStatus = status === true ? "up" : "down";
let statusChanged = false;
const checkSnapshot = this.toCheckSnapshot(check);
// Project the window as it will look after updating DB
// This is done because we need the updated status window to compute new status, but we don't
// want an an extra DB write just to get the window.
const projectedWindow = [...(monitor.statusWindow || []), check.status].slice(-monitor.statusWindowSize);
// Build the status patch — computed against the projected window
const patch: Partial<Monitor> = {};
// Resolve "initializing" up-front, incidents should be created on initialization && down
let newStatus: MonitorStatus = monitor.status;
let statusChanged = false;
if (monitor.status === "initializing") {
newStatus = status === true ? "up" : "down";
patch.status = newStatus;
statusChanged = newStatus === "down";
}
// Not enough data points yet — record the check and return
if (projectedWindow.length < monitor.statusWindowSize) {
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
monitor.id,
monitor.teamId,
check.status,
checkSnapshot,
monitor.statusWindowSize,
MAX_RECENT_CHECKS,
patch
);
// Return early if not enough data points
if (monitor.statusWindow.length < monitor.statusWindowSize) {
monitor.status = newStatus;
const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);
return {
monitor: updated,
statusChanged: false,
statusChanged,
prevStatus,
code,
timestamp: Date.now(),
};
}
// Check if threshold has been met
const failures = monitor.statusWindow.filter((s) => s === false).length;
const failureRate = (failures / monitor.statusWindow.length) * 100;
// If threshold has been met and the monitor is not already down, mark down:
if (failureRate >= monitor.statusWindowThreshold && monitor.status !== "down") {
newStatus = "down";
statusChanged = true;
}
// If the failure rate is below the threshold and the monitor is down, recover:
else if (failureRate < monitor.statusWindowThreshold && monitor.status === "down") {
newStatus = "up";
// First evaluate reachability-based status changes, which apply to all monitor types
// and take precedence over hardware breaches.
const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold);
if (reachabilityResult.transitioned) {
newStatus = reachabilityResult.nextStatus;
statusChanged = true;
}
// Evaluate hardware threshold breaches (only for hardware monitors)
let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined;
if (monitor.type === "hardware" && statusResponse.payload) {
const payload = statusResponse.payload as HardwareStatusPayload;
const metrics = payload?.data;
// Evaluate hardware threshold breaches (only for hardware monitors with metrics payload)
let thresholdBreaches: HardwareBreaches | undefined;
const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined;
if (monitor.type === "hardware" && hardwarePayload?.data) {
const hardware = this.computeHardwareStatus({
currentStatus: newStatus,
reachabilityDown: newStatus === "down",
metrics: hardwarePayload.data,
thresholds: {
cpu: monitor.cpuAlertThreshold,
memory: monitor.memoryAlertThreshold,
disk: monitor.diskAlertThreshold,
temp: monitor.tempAlertThreshold,
},
counters: {
cpu: monitor.cpuAlertCounter,
memory: monitor.memoryAlertCounter,
disk: monitor.diskAlertCounter,
temp: monitor.tempAlertCounter,
},
});
if (metrics) {
// Evaluate threshold breaches
const cpuUsage = metrics.cpu?.usage_percent ?? -1;
const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100;
const memoryUsage = metrics.memory?.usage_percent ?? -1;
const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100;
const diskBreach =
metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ??
false;
const temps = metrics.cpu?.temperature ?? [];
const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold);
thresholdBreaches = {
cpu: cpuBreach,
memory: memoryBreach,
disk: diskBreach,
temp: tempBreach,
};
// Update counters: decrement if breached, reset to 5 if not breached
if (cpuBreach) {
monitor.cpuAlertCounter = Math.max(0, monitor.cpuAlertCounter - 1);
} else {
monitor.cpuAlertCounter = 5;
}
if (memoryBreach) {
monitor.memoryAlertCounter = Math.max(0, monitor.memoryAlertCounter - 1);
} else {
monitor.memoryAlertCounter = 5;
}
if (diskBreach) {
monitor.diskAlertCounter = Math.max(0, monitor.diskAlertCounter - 1);
} else {
monitor.diskAlertCounter = 5;
}
if (tempBreach) {
monitor.tempAlertCounter = Math.max(0, monitor.tempAlertCounter - 1);
} else {
monitor.tempAlertCounter = 5;
}
// Check if any counter has reached zero (initial breach)
const anyCounterZero =
monitor.cpuAlertCounter === 0 || monitor.memoryAlertCounter === 0 || monitor.diskAlertCounter === 0 || monitor.tempAlertCounter === 0;
const anyThresholdBreached = cpuBreach || memoryBreach || diskBreach || tempBreach;
const allThresholdsNormal = !cpuBreach && !memoryBreach && !diskBreach && !tempBreach;
// Update monitor status based on threshold breach state
if (newStatus !== "down") {
// Don't override "down" status - service unreachable takes precedence
// Check current monitor status, not newStatus for comparison
if (anyCounterZero && anyThresholdBreached && monitor.status !== "breached") {
// Initial breach: counter hit zero, change status to breached
newStatus = "breached";
statusChanged = true;
} else if (anyCounterZero && anyThresholdBreached && monitor.status === "breached") {
// Already breached, keep status but don't mark as changed
newStatus = "breached";
// statusChanged remains false
} else if (allThresholdsNormal && monitor.status === "breached") {
// All thresholds returned to normal, recover from breached state
newStatus = "up";
statusChanged = true;
}
}
patch.cpuAlertCounter = hardware.nextCounters.cpu;
patch.memoryAlertCounter = hardware.nextCounters.memory;
patch.diskAlertCounter = hardware.nextCounters.disk;
patch.tempAlertCounter = hardware.nextCounters.temp;
thresholdBreaches = hardware.breaches;
if (hardware.transitioned) {
newStatus = hardware.nextStatus;
statusChanged = true;
}
}
// Apply the final status
monitor.status = newStatus;
patch.status = newStatus;
const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor);
// Single atomic write: push arrays + set status/counters
const updated = await this.monitorsRepository.updateStatusWindowAndChecks(
monitor.id,
monitor.teamId,
check.status,
checkSnapshot,
monitor.statusWindowSize,
MAX_RECENT_CHECKS,
patch
);
return {
monitor: updated,
@@ -366,27 +333,4 @@ export class StatusService implements IStatusService {
});
}
};
insertCheck = async (check: Check) => {
try {
if (typeof check === "undefined") {
this.logger.warn({
message: "Failed to build check",
service: SERVICE_NAME,
method: "insertCheck",
});
return false;
}
this.buffer.addToBuffer(check);
return true;
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : "Unknown error",
service: SERVICE_NAME,
method: "insertCheck",
details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` },
stack: error instanceof Error ? error.stack : undefined,
});
}
};
}
+7 -3
View File
@@ -13,12 +13,14 @@ export type EnvConfig = {
clientHost: string;
dbConnectionString: string;
dbType: DbType;
statusPageThemesEnabled: boolean;
};
export interface ISettingsService {
readonly serviceName: string;
loadSettings(): EnvConfig;
getSettings(): EnvConfig;
areStatusPageThemesEnabled(): boolean;
getDBSettings(): Promise<Settings>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
}
@@ -37,6 +39,7 @@ export class SettingsService implements ISettingsService {
clientHost: env.CLIENT_HOST,
dbConnectionString: env.DB_CONNECTION_STRING,
dbType: env.DB_TYPE,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
};
}
@@ -53,12 +56,13 @@ export class SettingsService implements ISettingsService {
}
getSettings() {
if (!this.settings) {
throw new Error("Settings have not been loaded");
}
return this.settings;
}
areStatusPageThemesEnabled() {
return this.settings.statusPageThemesEnabled;
}
private getRepository(): ISettingsRepository {
if (!this.settingsRepository) {
throw new AppError({
+1
View File
@@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick<
| "systemEmailHost"
| "systemEmailPort"
| "systemEmailAddress"
| "systemEmailDisplayName"
| "systemEmailPassword"
| "systemEmailUser"
| "systemEmailConnectionHost"
+2 -1
View File
@@ -1,4 +1,5 @@
export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const;
export type DurationUnit = (typeof DurationUnits)[number];
export interface MaintenanceWindow {
id: string;
+21 -1
View File
@@ -3,7 +3,7 @@ export type { CheckSnapshot } from "@/types/check.js";
import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js";
export type { GeoContinent } from "@/types/geoCheck.js";
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const;
export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
@@ -13,12 +13,30 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
export const UptimeDetailsSupportedTypes = [
"http",
"ping",
"docker",
"port",
"game",
"grpc",
"websocket",
"dns",
] as const satisfies readonly MonitorType[];
export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number];
export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type);
export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const;
export type MonitorStatus = (typeof MonitorStatuses)[number];
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
export type DnsRecordType = (typeof DnsRecordTypes)[number];
export const MAX_RECENT_CHECKS = 50;
export interface Monitor {
id: string;
userId: string;
@@ -58,6 +76,8 @@ export interface Monitor {
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
dnsServer?: string;
dnsRecordType?: DnsRecordType;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
+6
View File
@@ -1,3 +1,9 @@
export interface CheckResultInput {
status: boolean;
responseTime: number;
now: number;
}
export interface MonitorStats {
id: string;
monitorId: string;
+10
View File
@@ -8,6 +8,7 @@ import type {
CheckNetworkInterfaceInfo,
GotTimings,
ILighthouseAudit,
DnsRecordType,
Monitor,
MonitorMatchMethod,
MonitorType,
@@ -116,6 +117,14 @@ export interface WebSocketStatusPayload {
connected: boolean;
}
export interface DNSStatusPayload {
hostname: string;
dnsServer: string;
recordType: DnsRecordType;
resolved: boolean;
results: unknown;
}
export interface MonitorPayloadMap {
ping: PingStatusPayload;
http: HttpStatusPayload;
@@ -126,6 +135,7 @@ export interface MonitorPayloadMap {
game: GameStatusPayload;
grpc: GrpcStatusPayload;
websocket: WebSocketStatusPayload;
dns: DNSStatusPayload;
unknown: unknown;
}
+14 -1
View File
@@ -1,4 +1,15 @@
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const;
export const NotificationChannels = [
"email",
"slack",
"discord",
"webhook",
"pager_duty",
"matrix",
"teams",
"telegram",
"pushover",
"twilio",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
export interface Notification {
@@ -12,6 +23,8 @@ export interface Notification {
homeserverUrl?: string;
roomId?: string;
accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
createdAt: string;
updatedAt: string;
}
+2 -1
View File
@@ -1,4 +1,4 @@
export const DbTypes = ["timescaledb", "mongodb"] as const;
export const DbTypes = ["mongodb"] as const;
export type DbType = (typeof DbTypes)[number];
export interface SettingsThresholds {
@@ -21,6 +21,7 @@ export interface Settings {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailDisplayName?: string;
systemEmailPassword?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
+10
View File
@@ -1,6 +1,14 @@
export const StatusPageTypes = ["uptime", "infrastructure"] as const;
export type StatusPageType = (typeof StatusPageTypes)[number];
export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const;
export type StatusPageTheme = (typeof StatusPageThemes)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
export const StatusPageThemeModes = ["auto", "light", "dark"] as const;
export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number];
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
export interface StatusPageLogo {
data: string;
contentType: string;
@@ -30,6 +38,8 @@ export interface StatusPage {
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
theme: StatusPageTheme;
themeMode: StatusPageThemeMode;
createdAt: string;
updatedAt: string;
}
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more