Merge remote-tracking branch 'origin/develop' into feat/pagespeed-mobile-strat
This commit is contained in:
commit
78fdc548bb
395 files changed
+60597
-29063
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
export interface IDb {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
getPool(): Pool | null;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js";
|
||||
import { EnvConfig } from "@/service/system/settingsService.js";
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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" },
|
||||
|
||||
@@ -271,6 +271,7 @@ const CheckSchema = new Schema<CheckDocument>(
|
||||
{
|
||||
timestamps: true,
|
||||
strict: false,
|
||||
suppressReservedKeysWarning: true,
|
||||
timeseries: {
|
||||
timeField: "createdAt",
|
||||
metaField: "metadata",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -16,7 +16,7 @@ const MonitorStatsSchema = new Schema<MonitorStatsDocument>(
|
||||
monitorId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Monitor",
|
||||
index: true,
|
||||
unique: true,
|
||||
required: true,
|
||||
},
|
||||
avgResponseTime: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick<
|
||||
| "systemEmailHost"
|
||||
| "systemEmailPort"
|
||||
| "systemEmailAddress"
|
||||
| "systemEmailDisplayName"
|
||||
| "systemEmailPassword"
|
||||
| "systemEmailUser"
|
||||
| "systemEmailConnectionHost"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export interface CheckResultInput {
|
||||
status: boolean;
|
||||
responseTime: number;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface MonitorStats {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user